GraphQL предоставляет множество преимуществ, которых нет в традиционных REST API. GraphQL обеспечивает строгую типизацию для входных и выходных данных, поэтому гораздо сложнее ввести недопустимые входные и неожиданные выходные данные. Вы должны указать, какие поля вы хотите выводить, чтобы вы не получали много бесполезных данных, которые вам не нужны.

Кроме того, запросы GraphQL выполняются через обычный HTTP, поэтому обычные HTTP-клиенты могут делать запросы GraphQL.

Вы делаете такие запросы:

{
  getPhotos(page: 1) {
    photos {
      id
      fileLocation
      description
      tags
    }
    page
    totalPhotos
  }
}

В верхней строке находятся входные аргументы, а остальные - поля, которые вы хотите получить.

Чтобы упростить выполнение запросов, доступны клиенты GraphQL. Для Node.js пакет graphql-requests - отличная библиотека для создания запросов GraphQL.

Одним из примеров GraphQL API является Yelp GraphQL API. Приятно получать информацию о компаниях и событиях по всему миру. Он доступен бесплатно, и предел скорости высок. Он идеально подходит для создания приложений.

Он не поддерживает междоменные запросы, поэтому вам нужно использовать серверную часть для доступа к данным, если вы хотите передать их во внешний интерфейс.

Вы должны зарегистрироваться для получения ключа API, чтобы использовать Yelp API. Зарегистрируйтесь на сайте https://www.yelp.com/developers/documentation/v3/authentication, если хотите его использовать. Как и любой ключ API, он должен храниться в секретном месте.

Доступно много данных о компаниях. API возвращает имя, телефон, уровень цен, категории, часы работы, отзывы, фотографии и т. Д. В нем есть почти все, что большинство людей хотят знать о бизнесе.

В этой статье мы создадим приложение, которое использует Yelp API для поиска данных. Нам нужно серверное приложение для доступа к Yelp API, поскольку к нему нельзя получить доступ напрямую из браузера. Мы будем использовать GraphQL API для поиска данных о бизнесе и событиях. Он будет построен с помощью Express. Внешним приложением будет приложение React, которое предоставляет пользователям страницы поиска для поиска в Yelp API для бизнеса и событий через серверное приложение.

Для начала делаем пустую папку проекта. Затем мы создаем папку backend в папке проекта и в ней запускаем экспресс-генератор, чтобы сгенерировать скелетный код, запустив npx express-generator.

Как только это будет сделано, мы установим несколько необходимых нам пакетов. Нам нужен Babel для использования новейших функций JavaScript, пакет CORS для включения междоменных запросов в нашем приложении Express, dotenv для хранения переменных среды, таких как ключ Yelp API, и пакет graphql-request для выполнения запросов GraphQL к Yelp API.

Для этого запустите npm i @babel/cli @babel/core @babel/node @babel/preset-env cors graphql-request. Как только это будет сделано, добавьте следующее в раздел scripts файла package.json:

"start": "nodemon --exec npm run babel-node --  ./bin/www",
"babel-node": "babel-node"

Затем нам нужно создать файл с именем .babelrc в папке backend и добавить:

{
    "presets": [
        "@babel/preset-env"
    ]
}

чтобы указать, что в проекте будут использоваться новейшие функции JavaScript.

Это позволит пользователям запускать наше приложение с помощью Babel Node вместо обычного Node.js, который поддерживает новейшие функции JavaScript. Нам также потребуется установить nodemon, чтобы отслеживать изменения в наших файлах и перезагружать их при разработке приложения.

Далее мы можем написать некоторую логику. Нам нужны маршруты для получения данных от внешнего интерфейса к Yelp API, а затем для возврата результатов из Yelp API в виде JSON. Создайте файл с именем yelp.js и добавьте следующий код:

const express = require("express");
const router = express.Router();
const yelpApiUrl = "https://api.yelp.com/v3/graphql";
import { GraphQLClient } from "graphql-request";
const client = new GraphQLClient(yelpApiUrl, {
  headers: { Authorization: `Bearer ${process.env.YELP_API_KEY}` },
});
/* GET users listing. */
router.post("/business/search", async (req, res, next) => {
  const query = `
    query search($term: String!, $location: String!, $offset: Int!) {
      search(
        term: $term,
        location: $location,
        offset: $offset
      ) {
      total
      business {
        name
        rating
        review_count
        hours {
          is_open_now
          open {
            start
            end
            day
          }
        }
        location {
          address1
          city
          state
          country
        }
      }
    }
  }`;
const data = await client.request(query, req.body);
  res.json(data);
});
router.post("/events/search", async (req, res, next) => {
  const query = `
    query event_search($location: String!, $offset: Int!) {
      event_search(
        location: $location,
        offset: $offset
      ) {
      total
      events {
        name
        cost
        cost_max
      }
    }
  }`;
const data = await client.request(query, req.body);
  res.json(data);
});
router.post("/phone/search", async (req, res, next) => {
  const query = `
    query phone_search($phone: String!) {
      phone_search(
        phone: $phone
      ) {
      total
      business {
        name
        rating
        review_count
        location {
          address1
          city
          state
          country
        }
        hours {
          is_open_now
          open {
            start
            end
            day
          }
        }
      }
    }
  }`;
const data = await client.request(query, req.body);
  res.json(data);
});
module.exports = router;

В каждом маршруте у нас есть запрос на доступ к данным из Yelp GraphQL API. мы создали GraphQLClient путем создания экземпляра с YELP_API_KEY, который мы сохранили в файле .env в папке backend.

Файл .env должен быть таким:

YELP_API_KEY='your api key'

Чтобы получить запросы к API, мы используем песочницу GraphQL API, расположенную по адресу https://www.yelp.com/developers/graphql/query/business, чтобы возиться с запросом, пока мы не получим тот, который нам нужен. В песочнице есть автозаполнение, поэтому вам не нужно угадывать, что доступно. В этом прелесть API GraphQL. Входы и выходы строго типизированы, поэтому вы знаете, что можно вводить, а что можно получить на выходе. Над песочницей также есть документация по API.

Первая строка каждой строки запроса, например query search($term: String!, $location: String!, $offset: Int!), определяет ввод запроса. Восклицательный знак означает, что это необходимо. Указав эти параметры, мы можем передавать переменные как объекты во втором аргументе объекта client, чтобы передавать поля как переменные.

В конце концов, мы получаем ответ JSON, как и любой другой GraphQL API, и отправляем результат обратно клиенту.

Затем мы заменяем существующий код в app.js следующим:

require('dotenv').config();
const createError = require("http-errors");
const express = require("express");
const path = require("path");
const cookieParser = require("cookie-parser");
const logger = require("morgan");
const cors = require("cors");
const indexRouter = require("./routes/index");
const yelpRouter = require("./routes/yelp");
const app = express();
// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(cors());
app.use("/", indexRouter);
app.use("/yelp", yelpRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};
// render the error page
  res.status(err.status || 500);
  res.render("error");
});
module.exports = app;

В этом файле мы добавили app.use(cors());, чтобы включить CORS в нашем серверном приложении, и добавили:

const yelpRouter = require("./routes/yelp");
app.use("/yelp", yelpRouter);

чтобы открыть указанные нами маршруты. И мы добавили:

require('dotenv').config();

чтобы загрузить переменные среды, чтобы мы могли использовать их в нашем приложении.

Теперь, когда бэкэнд готов, мы можем перейти к переднему краю.

Мы начинаем с запуска приложения Create React, чтобы создать файлы для внешнего приложения. Мы делаем это, запустив npx create-react-app frontend в корневой папке проекта.

Далее мы устанавливаем несколько пакетов. Нам нужны Axios для выполнения HTTP-запросов к нашему внутреннему приложению, Bootstrap для стилизации, React Router для маршрутизации и Formik и Yup для обработки изменений входных значений и проверки формы соответственно.

Запускаем npm i axios bootstrap formik react-bootstrap react-router-dom yup, чтобы установить все пакеты.

Теперь мы готовы написать код. Все будет в папке src, если не указано иное. В App.js мы заменяем существующий код следующим:

import React from "react";
import "./App.css";
import TopBar from "./TopBar";
import { Router, Route, Link } from "react-router-dom";
import { createBrowserHistory as createHistory } from "history";
import HomePage from "./HomePage";
import PhoneSearchPage from "./PhoneSearchPage";
import EventSearchPage from "./EventSearchPage";
const history = createHistory();
function App() {
  return (
    <div className="App">
      <Router history={history}>
        <TopBar />
        <Route path="/" exact component={HomePage} />
        <Route path="/phonesearch" exact component={PhoneSearchPage} />
        <Route path="/eventsearch" exact component={EventSearchPage} />
      </Router>
    </div>
  );
}
export default App;

У нас есть маршруты на 3 страницы. Один для домашней страницы, один для поиска по телефону и один для поиска событий. У нас также есть верхняя панель со ссылками на эти страницы.

В App.css мы заменяем существующий код следующим:

.center {
  text-align: center;
}

для центрирования текста.

Далее мы создаем страницу поиска событий. Создайте файл с именем EventSearchPage.js и добавьте следующее:

import React, { useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import Card from "react-bootstrap/Card";
import Pagination from "react-bootstrap/Pagination";
import * as yup from "yup";
import { searchEvents } from "./requests";
const schema = yup.object({
  location: yup.string().required("Location is required"),
});
function EventSearchPage() {
  const [results, setResults] = useState([]);
  const [offset, setOffset] = useState(0);
  const [page, setPage] = useState(1);
  const [total, setTotal] = useState(0);
  const [params, setParams] = useState({});
const getEvents = async params => {
    const response = await searchEvents(params);
    setTotal(response.data.event_search.total);
    setResults(response.data.event_search.events);
  };
const changePage = async page => {
    setPage(page);
    setOffset((page - 1) * 20);
    params.offset = (page - 1) * 20;
    setParams(params);
    getEvents(params);
  };
const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    evt.offset = offset;
    setParams(evt);
    getEvents(evt);
  };
return (
    <div className="home-page">
      <h1 className="center">Event Search</h1>
      <Formik validationSchema={schema} onSubmit={handleSubmit}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="name">
                <Form.Label>Location</Form.Label>
                <Form.Control
                  type="text"
                  name="location"
                  placeholder="Location"
                  value={values.location || ""}
                  onChange={handleChange}
                  isInvalid={touched.location && errors.location}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.location}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit">Search</Button>
          </Form>
        )}
      </Formik>
      <br />
      {results.map((r, i) => {
        return (
          <Card key={i}>
            <Card.Title className="card-title">{r.name}</Card.Title>
            <Card.Body></Card.Body>
          </Card>
        );
      })}
      <br />
      <Pagination>
        <Pagination.First onClick={changePage.bind(this, 1)} />
        <Pagination.Prev
          onClick={changePage.bind(this, page - 1)}
          disabled={page == 0}
        />
        <Pagination.Next
          onClick={changePage.bind(this, page + 1)}
          disabled={Math.ceil(total / 20) == page}
        />
        <Pagination.Last
          onClick={changePage.bind(this, Math.max(Math.floor(total / 20)), 0)}
        />
      </Pagination>
    </div>
  );
}
export default EventSearchPage;

Мы оборачиваем форму React Boostrap компонентом Formik, чтобы использовать функции обработки значений формы Formik, автоматически привязывая входные значения к объекту evt в функции handleSubmit. Объект schema создается с помощью Yup для проверки наличия обязательных полей, что составляет location в этой форме.

В функции handleSubmit мы получаем введенное значение в объекте evt в параметре, мы проверяем его с помощью функции schema.validate Yup. Если isValid равно true, сделайте запрос к нашему API, вызвав функцию getEvents. Мы устанавливаем offset, чтобы мы могли просматривать результаты с разбивкой на страницы, пропуская количество записей, указанное в offset. Мы получаем ответ и устанавливаем результаты, вызывая setResults, чтобы мы могли отображать результаты в компоненте Card под Form. У нас также есть кнопки для Pagination, чтобы мы могли пропустить результаты, чтобы добраться до тех, которые нам нужны. Мы следим за тем, чтобы ссылки были отключены, когда они не имеют смысла, например отключение кнопки Pagination.Next, когда нет следующих записей, и Pagination.Last, когда пользователь находится на последней странице.

Затем нам нужен массив дней недели для определения константы days. Мы создаем файл с именем exports.js и добавляем следующее:

export const days = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
];

Далее мы создаем домашнюю страницу. Создайте файл с именем HomePage.js и добавьте:

import React, { useState } from "react";
import "./HomePage.css";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import Card from "react-bootstrap/Card";
import Pagination from "react-bootstrap/Pagination";
import * as yup from "yup";
import { searchBusiness } from "./requests";
import { days } from "./exports";
const schema = yup.object({
  term: yup.string().required("Term is required"),
  location: yup.string().required("Location is required"),
});
function HomePage() {
  const [results, setResults] = useState([]);
  const [offset, setOffset] = useState(0);
  const [page, setPage] = useState(1);
  const [total, setTotal] = useState(0);
  const [params, setParams] = useState({});
const getBusiness = async params => {
    const response = await searchBusiness(params);
    setTotal(response.data.search.total);
    setResults(response.data.search.business);
  };
const changePage = async page => {
    setPage(page);
    setOffset((page - 1) * 20);
    params.offset = (page - 1) * 20;
    setParams(params);
    getBusiness(params);
  };
const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    evt.offset = offset;
    setParams(evt);
    getBusiness(evt);
  };
return (
    <div className="home-page">
      <h1 className="center">Business Search</h1>
      <Formik validationSchema={schema} onSubmit={handleSubmit}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="name">
                <Form.Label>Term</Form.Label>
                <Form.Control
                  type="text"
                  name="term"
                  placeholder="Term"
                  value={values.term || ""}
                  onChange={handleChange}
                  isInvalid={touched.term && errors.term}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.term}
                </Form.Control.Feedback>
              </Form.Group>
              <Form.Group as={Col} md="12" controlId="url">
                <Form.Label>Location</Form.Label>
                <Form.Control
                  type="text"
                  name="location"
                  placeholder="Location"
                  value={values.location || ""}
                  onChange={handleChange}
                  isInvalid={touched.location && errors.location}
                />
<Form.Control.Feedback type="invalid">
                  {errors.location}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit">Search</Button>
          </Form>
        )}
      </Formik>
      <br />
      {results.map((r, i) => {
        return (
          <Card key={i}>
            <Card.Title className="card-title">{r.name}</Card.Title>
            <Card.Body>
              <p>
                Address: {r.location.address1}, {r.location.city},{" "}
                {r.location.country}, {r.location.state}
              </p>
              <p>Rating: {r.rating}</p>
              <p>Open Now: {r.hours[0].is_open_now ? "Yes" : "No"}</p>
              <p>Hours:</p>
              <ul>
                {r.hours[0] &&
                  r.hours[0].open.map((h, i) => (
                    <li key={i}>
                      {days[h.day]}: {h.start} - {h.end}
                    </li>
                  ))}
              </ul>
            </Card.Body>
          </Card>
        );
      })}
      <br />
      <Pagination>
        <Pagination.First onClick={changePage.bind(this, 1)} />
        <Pagination.Prev
          onClick={changePage.bind(this, page - 1)}
          disabled={page == 0}
        />
        <Pagination.Next
          onClick={changePage.bind(this, page + 1)}
          disabled={Math.ceil(total / 20) == page}
        />
        <Pagination.Last
          onClick={changePage.bind(this, Math.max(Math.floor(total / 20)), 0)}
        />
      </Pagination>
    </div>
  );
}
export default HomePage;

Он очень похож на EventSearchPage, за исключением того, что у нас больше полей. У нас также есть дополнительные данные в Card компонентах результатов. Логика получения данных и разбивки на страницы такая же, как у EvenrSearchPage, за исключением того, что мы получаем предприятия вместо событий.

Затем мы создаем файл с именем HomePage.css и добавляем:

.home-page {
    padding: 20px;
}
.card-title {
    margin: 0 20px;
}
export default HomePage;

чтобы добавить поля к нашему тексту и отступы на нашу страницу.

Далее делаем страницу для поиска предприятий по телефону. Создайте файл с именем PhoneSearchPage.js и добавьте следующее:

import React, { useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import Card from "react-bootstrap/Card";
import Pagination from "react-bootstrap/Pagination";
import * as yup from "yup";
import { searchPhone } from "./requests";
import { days } from "./exports";
const schema = yup.object({
  phone: yup.string().required("Phone is required"),
});
function PhoneSearchPage() {
  const [results, setResults] = useState([]);
  const [offset, setOffset] = useState(0);
  const [page, setPage] = useState(1);
  const [total, setTotal] = useState(0);
  const [params, setParams] = useState({});
const getEvents = async params => {
    const response = await searchPhone(params);
    setTotal(response.data.phone_search.total);
    setResults(response.data.phone_search.business);
  };
const changePage = async page => {
    setPage(page);
    setOffset((page - 1) * 20);
    params.offset = (page - 1) * 20;
    setParams(params);
    getEvents(params);
  };
const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    evt.offset = offset;
    setParams(evt);
    getEvents(evt);
  };
return (
    <div className="home-page">
      <h1 className="center">Phone Search</h1>
      <Formik validationSchema={schema} onSubmit={handleSubmit}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="name">
                <Form.Label>Phone</Form.Label>
                <Form.Control
                  type="text"
                  name="phone"
                  placeholder="Phone"
                  value={values.phone || ""}
                  onChange={handleChange}
                  isInvalid={touched.phone && errors.phone}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.phone}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit">Search</Button>
          </Form>
        )}
      </Formik>
      <br />
      {results.map((r, i) => {
        return (
          <Card key={i}>
            <Card.Title className="card-title">{r.name}</Card.Title>
            <Card.Body>
              <p>
                Address: {r.location.address1}, {r.location.city},{" "}
                {r.location.country}, {r.location.state}
              </p>
              <p>Rating: {r.rating}</p>
              <p>Open Now: {r.hours[0].is_open_now ? "Yes" : "No"}</p>
              <p>Hours:</p>
              <ul>
                {r.hours[0] &&
                  r.hours[0].open.map((h, i) => (
                    <li key={i}>
                      {days[h.day]}: {h.start} - {h.end}
                    </li>
                  ))}
              </ul>
            </Card.Body>
          </Card>
        );
      })}
      <br />
      <Pagination>
        <Pagination.First onClick={changePage.bind(this, 1)} />
        <Pagination.Prev
          onClick={changePage.bind(this, page - 1)}
          disabled={page == 0}
        />
        <Pagination.Next
          onClick={changePage.bind(this, page + 1)}
          disabled={Math.ceil(total / 20) == page}
        />
        <Pagination.Last
          onClick={changePage.bind(this, Math.max(Math.floor(total / 20)), 0)}
        />
      </Pagination>
    </div>
  );
}
export default PhoneSearchPage;

Это похоже на HomePage, за исключением того, что мы ищем по телефону, а не по ключевому слову и местоположению. Пагинация такая же, как HomePage.

Затем мы создаем файл кода для хранения запросов. Создайте файл с именем requests.js и добавьте:

const axios = require("axios");
const apiUrl = "http://localhost:3000";
export const searchBusiness = data =>
  axios.post(`${apiUrl}/yelp/business/search`, data);
export const searchEvents = data =>
  axios.post(`${apiUrl}/yelp/events/search`, data);
export const searchPhone = data =>
  axios.post(`${apiUrl}/yelp/phone/search`, data);

Это код для выполнения HTTP-запросов, которые мы импортировали на страницы.

Далее создаем верхнюю планку. Создайте файл с именем TopBar.js и добавьте:

import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import { withRouter } from "react-router-dom";
function TopBar({ location }) {
  const { pathname } = location;
return (
    <Navbar bg="primary" expand="lg" variant="dark">
      <Navbar.Brand href="#home">Yelp App</Navbar.Brand>
      <Navbar.Toggle aria-controls="basic-navbar-nav" />
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav className="mr-auto">
          <Nav.Link href="/" active={pathname == "/"}>
            Business Search
          </Nav.Link>
          <Nav.Link href="/eventsearch" active={pathname == "/eventsearch"}>
            Events Search
          </Nav.Link>
          <Nav.Link href="/phonesearch" active={pathname == "/phonesearch"}>
            Phone Search
          </Nav.Link>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}
export default withRouter(TopBar);

Мы используем Navbar, предоставленный React Bootstrap, и добавляем ссылки для перехода на созданные нами страницы. Мы устанавливаем опору active, проверяя, на какой странице мы в данный момент находимся, получая pathname из опоры location, которая предоставляется функцией withRouter, находящейся за пределами TopBar.

Наконец, в index.html замените существующий код на:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>Yelp App</title>
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

чтобы изменить заголовок и добавить CSS Bootstrap в наше приложение.