Conceitos, Ideias, Estratégias
Conceitos, Ideias, EstratégiasProjetando a aplicação para funcionar com diferentes servidores GraphQL

Projetando a aplicação para funcionar com diferentes servidores GraphQL

"Programar contra interfaces, não implementações" é a prática de invocar uma funcionalidade não diretamente, mas por meio de um contrato que enumera quais entradas são necessárias e qual é a saída esperada, ocultando como a implementação é realizada. Essa estratégia ajuda a desacoplar a aplicação de uma implementação, provedor ou stack específicos, possibilitando a troca entre eles sem precisar alterar o código da aplicação.

Podemos aplicar essa estratégia com GraphQL também. O GraphQL pode atuar como intermediário entre a aplicação e o servidor, permitindo que executemos todas as modificações necessárias apenas nas queries GraphQL, mantendo a lógica de negócio intacta.

Uma query GraphQL atua como uma interface entre o cliente e o servidor. Ao executar uma query, o servidor GraphQL a processará e retornará os dados necessários ao cliente. De onde vêm os dados? Como foram obtidos? O cliente não sabe e não se importa.

A query GraphQL atua como uma interface entre o cliente e o servidor

A resposta à query terá o mesmo formato da query. Para esta query GraphQL:

{
  post(by: { id: 1 }) {
    id
    title
  }
}

...a resposta será:

{
  "data": {
    "post": {
      "id": 1,
      "title": "Hello world!"
    }
  }
}

Dada a mesma query com parâmetros diferentes, os dados retornados serão diferentes, mas o formato será constante. Isso significa que, enquanto a query não mudar, a aplicação não precisará alterar sua lógica de como ler e processar os dados, e da mesma forma não importará qual servidor GraphQL está executando a query.

E assim podemos substituir um servidor GraphQL por outro de forma totalmente transparente.

As queries dependem do schema GraphQL

Agora, o último parágrafo é um pouco otimista demais, porque a query GraphQL pode precisar mudar dependendo do servidor GraphQL. Para ser mais preciso, a query é baseada no schema GraphQL, e se servidores diferentes expõem schemas diferentes, então a query também será diferente.

Por exemplo, um servidor GraphQL que utiliza a Cursor Connections Specification pode executar a seguinte query:

{
  categories(first: 10000) {
    edges {
      node {
        categoryId
        description
        id
        name
        slug
      }
    }
  }
}

E outro servidor que utiliza paginação no estilo WordPress (como o Gato GraphQL) executará a mesma query assim:

{
  postCategories(pagination: { limit: 10000 }) {
    id
    description
    globalID
    name
    slug
  }
}

Podemos perceber as diferenças entre as duas queries:

CaracterísticaServidor #1Servidor #2
Campo de categorias de postcategoriespostCategories
Argumento de campo para limitar o número de resultadosfirstpagination.limit
O campo id de um objeto representaseu ID global únicoseu ID único para seu tipo
Formato da querymais profundo por causa de edges.nodemais plano

Substituir a query do primeiro servidor pela equivalente do segundo dentro da aplicação sozinha não funcionará. Isso porque a lógica ainda acessará os dados da resposta de acordo com o formato e os campos da query original.

Uma solução possível é também substituir a lógica para recuperar os dados no cliente. Por exemplo, a seguinte lógica:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

...pode ser substituída assim:

const categories = data?.data.postCategories;

Mas isso é exatamente o que queremos evitar. Queremos manter as alterações no mínimo necessário, modificando apenas a interface (a query GraphQL) e mantendo a lógica de negócio sem modificações.

Felizmente, é possível preencher as diferenças modificando apenas as queries GraphQL, seguindo estas etapas:

  1. Manter as queries GraphQL desacopladas da aplicação
  2. Adaptar os nomes dos campos via aliases
  3. Adaptar o formato da resposta via um campo self

Vejamos como, por meio dessas 3 etapas, podemos adaptar uma aplicação para apontar para um servidor GraphQL diferente.

Mantendo as queries GraphQL desacopladas da aplicação

Desacoplar as queries GraphQL da lógica da aplicação envolve:

  • Armazenar cada query GraphQL (ou um conjunto delas) em um arquivo separado, e todas elas em uma pasta específica
  • Exportar as queries e importá-las na aplicação

Por exemplo, podemos colocar cada query GraphQL em um arquivo separado sob src/data e exportá-la:

// file `src/data/categories.js`
export const QUERY_ALL_CATEGORIES = gql`
  {
    categories(first: 10000) {
      edges {
        node {
          databaseId
          description
          id
          name
          slug
        }
      }
    }
  }
`;

A aplicação pode então importar e usar a query GraphQL:

import { QUERY_ALL_CATEGORIES } from 'data/categories';
 
export async function getAllCategories() {
  const apolloClient = getApolloClient();
 
  const data = await apolloClient.query({
    query: QUERY_ALL_CATEGORIES,
  });
 
  const categories = data?.data.categories.edges.map(({ node = {} }) => node);
 
  return {
    categories,
  };
}

Graças a essa configuração, todas as modificações precisam ser feitas apenas nos arquivos sob src/data.

Adaptando os nomes dos campos via aliases

Um alias de campo pode ser usado para renomear um campo na resposta do segundo servidor GraphQL com o nome desse campo no primeiro servidor.

Dessa forma, os campos postCategories, id e globalID podem ser recuperados usando os nomes esperados pela aplicação: categories, categoryId e id, respectivamente:

{
  categories: postCategories(pagination: { limit: 10000 }) {
    categoryId: id
    description
    id: globalID
    name
    slug
  }
}

Observe que o campo categories tem o argumento first, enquanto seu campo correspondente postCategories usa o argumento pagination.limit. No entanto, como os argumentos de campo não se refletem no nome do campo na resposta, não precisamos nos preocupar com eles.

Adaptando o formato da resposta via um campo self

O desafio final é um pouco mais complicado: precisamos modificar o formato da resposta, adicionando os níveis extras para edges e node provenientes da spec Cursor Connections.

Para isso, introduziremos um campo self em todos os tipos no schema GraphQL, que ecoa de volta o mesmo objeto onde é aplicado:

type QueryRoot {
  self: QueryRoot!
}
 
type Post {
  self: Post!
}
 
type User {
  self: User!
}

O campo self permite adicionar níveis extras à query sem sair do objeto consultado. Executando esta query:

{
  __typename
  self {
    __typename
  }
  
  post(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
  
  user(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
}

...produz esta resposta:

{
  "data": {
    "__typename": "QueryRoot",
    "self": {
      "__typename": "QueryRoot"
    },
    "post": {
      "self": {
        "id": 1,
        "__typename": "Post"
      }
    },
    "user": {
      "self": {
        "id": 1,
        "__typename": "User"
      }
    }
  }
}

Agora, podemos usar self para adicionar artificialmente os níveis nodes e edge:

{
  categories: self {
    edges: postCategories(pagination: { limit: 10000 }) {
      node: self {
        categoryId: id
        description
        id: globalID
        name
        slug
      }
    }
  }
}

O tipo do objeto no schema GraphQL para edges e para self é obviamente diferente. Mas isso não importa para a aplicação, porque ela não interage com o objeto real modelado no servidor GraphQL. Em vez disso, ela recebe os dados como um objeto JSON, e essa porção de dados para um campo proveniente de um objeto PostConnection ou de um objeto Post será a mesma.

Observe que o campo categories é resolvido via self e edges é resolvido via postCategories, e não o contrário. Isso é para manter a cardinalidade dos elementos retornados correspondente à definida pelos campos que utilizam a spec Cursor Connections:

type RootQuery {
  categories: RootQueryToCategoryConnection
}
 
type RootQueryToCategoryConnection {
  edges: [RootQueryToCategoryConnectionEdge]
}
 
type RootQueryToCategoryConnectionEdge {
  node: Category
}

Se a query GraphQL adaptada fosse invertida (ou seja, consultando categories: postCategories e edges: self), o acesso aos dados falharia, porque data.categories seria um array, então data.categories.edges lançaria um erro ao executar:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

Adaptando todas as queries

Após aplicar a mesma estratégia a todas as queries GraphQL em src/data, a aplicação pode facilmente trocar de um servidor GraphQL para outro.