GraphQL + Apollo на клиенте

Небольшой рассказ о технологии GraphQL, а также пример её использования на фронтенде вместе с библиотекой Apollo.

GraphQL - это особый язык запросов между клиентом и сервером. На низком уровне так же, как в REST- или RPC-архитектуре, используется обычный HTTP-протокол. Различие в двух основных вещах: как формируется тело запроса и как этот запрос обрабатывается сервером.

Запрос всегда выполняется методом POST (мы отличие от REST, не надо думать, какой метод использовать). Обработка запросов GraphQL происходит по единственному адресу, который можно прописать в конфиге веб-приложения. Таким образом, в отличие от REST-архитектуры, GraphQL всегда обращается по одному адресу с помощью одного метода, что упрощает применение на фронт-енде.

В теле HTTP запроса объект GraphQL переводится в JSON-подобную строку, ответом является объект data c JSON внутри. Посмотреть, как это работает, можно здесь: https://docs.github.com/en/graphql/overview/explorer . Нужно лишь зайти под своим логином из GitHub:

// GraphQL
query { 
  viewer { 
    login
  }
}
// JSON
{
  "data": {
    "viewer": {
      "login": "botyaslonim"
    }
  }
}

Посмотрим на конструкцию запроса:

import gql from 'graphql-tag';

const statitonFragment = gql`
fragment fuel_stationFragment on fuel_station {
latitude
longitude
name
operator
picture
status
}` export default gql` query getFuelStation($id: bigint!) { fuel_station_by_pk(id: $id) { brand fuel_station_pictures { id x y zoom image_key } id ...fuel_stationFragment picture status test_purchases(order_by: { date: asc }, where: { active: { _eq: true } }) { active ... } user_comments { rating } } content { id key text } }
${statitonFragment}
`;

В тело запроса можно помещать дополнительные инструкции для бэка (на проекте из примера бэкенд на Hasura), такие как order_by и другие для релевантного отображения данных (по Hasura см. подробную документацию).

Выше используется техника фрагментов: часть запроса можно хранить отдельно для дальнейшего переиспользования. Для упрощения написания запроса использована утилита graphql-tag

Сам запрос имеет три важных части:
 - operation type (query | mutation | subscription), в нашем случае query ( о типах ниже);
 - operation name - имя запроса для удобства отладки и тестирования, в нашем случае getFuelStation;
 - variable definitions - переменные запроса, у нас это $id.

Видно, то переменная может использоваться внутри любой части запроса. Сами поля запроса - это JSON-поля, значения которых должен вернуть сервер. Мы просто передаём список того, что хотим получить и в какой структуре.

Для изменения данных (аналог REST-овских POST, PUT) вместо query используется mutation:

 
import gql from 'graphql-tag';

export default gql`
  mutation addComment(
    $fuelStationId: bigint!
    $userId: Int!
    $text: String!
    $rating: numeric!
    $userName: String!
    $userPicture: String!
  ) {
    insert_user_comment(
      objects: {
        fuel_station_id: $fuelStationId
        user_id: $userId
        text: $text
        rating: $rating
        user_name: $userName
        user_picture: $userPicture
      }
    ) {
      returning {
        id
        rating
        text
        user_name
        ...
        fuel_station {
          picture
          id
          latitude
          longitude
          ...
          user_comments {
            rating
          }
          fuel_station_pictures {
            url
          }
          test_purchases {
            rating
            octane_rating
            volume_rating
          }
        }
      }
    }
  }
`;

Вызов, понятно, происходит не при рендере компонента (как с получением данных query), а при определённых действиях:

 
<Formik
    onSubmit={(values, { setSubmitting, resetForm }) => {
      client
       .mutate({
           mutation: addComment,
           variables: {
               fuelStationId: station.id,
               userId: user.id,
               text: values.text,
               rating: values.rating,
               userName: values.userName,
               userPicture: values.userPicture,
             },
            ...
            ...

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

Операция subscription устанавливает подписку на серверные события через WebSocket.

Apollo - наиболее распостранённая библиотека для работы с GraphQL-запросами. Ниже пример её использования (за основу взяты React-компоненты на классах).

Чтобы подключить Apollo, нужно создать клиент:

export default new ApolloClient({
  uri: '.../graphql',
  headers: token
    ? {
        authorization: `Bearer ${token}`,
        'content-type': 'application/json',
      }
    : {
        'content-type': 'application/json',
      },
});

Далее импортировать его в компоненте верхнего уровня:

import client from './GraphQL/client';

class Index extends Component {
  render = () => (
    <ApolloProvider client={client}>     
      <App />       
    </ApolloProvider>
  );
}

На данный момент рекомендуется использовать React на хуках. Для этого у Apollo существует хук useQuery, который возвращает три объекта - статус загрузки, ошибку и сами данные. Пример из туториала:

import { gql, useQuery } from '@apollo/client';

const GET_DOGS = gql`
  query GetDogs {
    dogs {
      id
      breed
    }
  }
`;
function Dogs({ onDogSelected }) {
  const { loading, error, data } = useQuery(GET_DOGS);

  if (loading) return 'Loading...';
  if (error) return `Error! ${error.message}`;

   return (
    <select name="dog" onChange={onDogSelected}>
      {data.dogs.map(dog => (
        <option key={dog.id} value={dog.breed}>
          {dog.breed}
        </option>
      ))}
    </select>
  );
}

Запросы кэшируются автоматически, однако функциональностью кэширования можно управлять (стратегии polling, refetching, см. документацию).

Но с помощью Apollo в React удобно делать запросы и в классовых компонентах:

import stationQuery from '../../GraphQL/queries/stationShort.js';
import { Query } from 'react-apollo';
...
export default class Line extends Component {
  render() {
    const { station } = this.props;

    return (
      <Query
        query={stationQuery}
        variables={{
          id: station,
        }}
      >
        {({ error, loading, data }) => (
          <View
            station={data && data.fuel_station_by_pk ? data.fuel_station_by_pk : null}
            error={error}
            loading={loading}
          />
        )}
      </Query>
    );
  }
}

Здесь видно, что в компоненте-обёртке (Query) нужно передать собственно запросы (query), а также - если нужно - его переменные. Дело в том, что часть полей запроса может формироваться динамически, на основе переменных.