Trabalhando com Field Resolver no Apollo Server
graphqlnodejsComo juntar dados de vários datasources em uma única query, e só buscar esses dados quando alguém pedir. #
Trabalho com GraphQL há algum tempo, e uma das dúvidas que eu tive no início foi como juntar dados de vários datasources em uma única query, criando um resolver específico para um campo, que só seria chamado caso esse campo fosse pedido. Na época encontrei pouca documentação sobre isso, então vou compartilhar um pouco do que eu aprendi sobre como funcionam os resolvers no Apollo Server.
Os exemplos usam o @apollo/server 5 com TypeScript e ESM. Se você ainda usa o apollo-server v2 ou v3, deixei uma nota no final sobre o que muda.
Neste exemplo, vamos definir uma query que busca o post pelo id e retorna os campos id, title, content e comments. O campo comments retorna uma lista de comentários com os campos id, author e content.
Nosso schema ficaria assim:
type Comment {
id: ID!
author: String!
content: String!
}
type Post {
id: ID!
title: String!
content: String!
comments: [Comment]!
}
type Query {
post(id: ID!): Post!
}
O detalhe aqui é que o post e os comentários vêm de endpoints diferentes da nossa API Rest. O post vem de /posts/:id e os comentários de /posts/:id/comments. Para quem consome o schema isso não aparece, o client só pede o post com os comentários e pronto. Essa é a parte bem legal.
O resolver da query #
Vamos criar um resolver para a query que busca o post em uma API Rest de exemplo:
import { BlogService } from '../../services/blog.js'
export const post = async (_parent: unknown, args: { id: string }) => {
return BlogService.getPostById(args.id)
}
Esse resolver cuida só do campo post do tipo Query. Ele recebe o id em args e retorna o que a API Rest devolver, provavelmente algo como { id, title, content }, sem os comentários.
E foi aqui que eu travei na época: se a API não devolve comments, como o campo comments do schema é preenchido?
Todo campo tem um resolver #
No GraphQL, cada campo do schema tem um resolver. Não existe só o resolver da query, existe um resolver para cada campo, e o Apollo vai resolvendo a query campo a campo, descendo pela árvore.
Quando você não escreve um resolver para um campo, o Apollo usa o default resolver, que faz basicamente isso:
const defaultResolver = (parent, args, context, info) => parent[info.fieldName]
Ou seja, ele pega o objeto que o resolver de cima retornou e lê a propriedade com o mesmo nome do campo. Por isso title e content funcionam sem a gente escrever nada, eles já vêm no objeto que o getPostById retornou.
Com comments é diferente. Essa propriedade não existe no objeto, então o default resolver retornaria undefined. É aí que entra o field resolver.
Criando o field resolver #
Com o post carregado pela query, podemos criar um resolver que usa o id do post para buscar os comentários:
import { BlogService } from '../../services/blog.js'
import type { Post } from '../../types.js'
export const comments = async (parent: Post) => {
return BlogService.getCommentsByPostId(parent.id)
}
O pulo do gato está no primeiro argumento. Todo resolver recebe os mesmos quatro argumentos:
parent: o resultado do resolver de cima. Aqui é o post que oQuery.postretornou, e é dele que pegamos oid;args: os argumentos do campo no schema. NoQuery.postera oid, aqui não temos nenhum;context: um objeto compartilhado por toda a request, onde costumam ficar o usuário autenticado, datasources e loaders;info: informações sobre a execução, incluindo quais campos foram pedidos.
Repare que o id veio do parent, e não do args. O field resolver não sabe nada sobre a query original, ele só sabe resolver o campo comments de um Post que já foi carregado.
Juntando tudo no resolver map #
Para o Apollo usar esses resolvers, precisamos montar um objeto com a mesma estrutura do schema:
import { post } from './Query/post.js'
import { comments } from './Post/comments.js'
export const resolvers = {
Query: {
post,
},
Post: {
comments,
},
}
A primeira chave é o tipo e a de dentro é o campo. O Post.comments diz para o Apollo que, sempre que precisar do campo comments de um Post, ele deve chamar essa função no lugar do default resolver.
Os imports terminam em
.jsmesmo os arquivos sendo.ts. Essa é a convenção de ESM no TypeScript, você escreve o caminho do arquivo que vai existir depois de compilado.
Ele só roda se alguém pedir #
Essa era a parte que eu queria entender desde o começo. Se o client manda essa query:
query {
post(id: "1") {
title
}
}
O resolver de comments não é chamado, e nenhuma request vai para /posts/1/comments. O Apollo só executa o resolver dos campos que aparecem na query.
Agora, se o client pedir os comentários:
query {
post(id: "1") {
title
comments {
author
content
}
}
}
Aí sim ele roda, e as duas chamadas acontecem em sequência: primeiro o post e depois os comentários. Foi aí que a ficha caiu pra mim. Com field resolver dá para montar um schema bem completo, com dados de vários lugares, sem precisar chamar todos os datasources em toda request.
O problema do N+1 #
Agora imagine que o schema também tem uma query posts, que retorna uma lista de posts, e o client manda a seguinte query:
query {
posts {
title
comments {
author
}
}
}
Com 50 posts na lista, o resolver de comments é chamado 50 vezes, uma para cada post. Ou seja, são 51 requests para a API Rest: 1 para a lista e 50 para os comentários. Para quem fez a query parece uma chamada só, mas quem paga a conta é a API Rest. Esse é o famoso problema do N+1, e é bem comum ele aparecer quando começamos a usar field resolvers.
A solução mais usada é o DataLoader. Ele junta todas as chamadas feitas no mesmo tick do event loop e faz uma chamada só, em lote:
import DataLoader from 'dataloader'
import { BlogService } from '../services/blog.js'
import type { Comment } from '../types.js'
export const createCommentsLoader = () =>
new DataLoader<string, Comment[]>(async (postIds) => {
const comments = await BlogService.getCommentsByPostIds([...postIds])
// O DataLoader exige um array de retorno na MESMA ordem dos ids pedidos.
return postIds.map((postId) =>
comments.filter((comment) => comment.postId === postId)
)
})
O loader fica no context. E aqui tem uma mudança importante em relação às versões antigas do Apollo Server: o context não é mais uma opção do construtor, agora ele fica no startStandaloneServer.
Primeiro vamos criar o tipo do context em um arquivo separado. Se você declarar ele no index.ts, os resolvers vão importar do index.ts, que importa os resolvers, e isso cria um import circular:
// src/context.ts
import type { createCommentsLoader } from './loaders/comments.js'
export interface Context {
loaders: {
comments: ReturnType<typeof createCommentsLoader>
}
}
Agora basta criar o loader dentro do context na configuração do servidor:
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { typeDefs } from './schema.js'
import { resolvers } from './resolvers/index.js'
import { createCommentsLoader } from './loaders/comments.js'
import type { Context } from './context.js'
const server = new ApolloServer<Context>({ typeDefs, resolvers })
const { url } = await startStandaloneServer(server, {
context: async () => ({
loaders: {
comments: createCommentsLoader(),
},
}),
})
console.log(`🚀 Server pronto em ${url}`)
Repare que o context é uma função, e ela roda uma vez a cada request. Isso é importante porque garante um loader novo em cada request. Se você criar o loader uma vez só, fora da função, o cache dele fica global e pode misturar dados de usuários diferentes.
Com o Context tipado, o field resolver fica assim:
import type { Post } from '../../types.js'
import type { Context } from '../../context.js'
export const comments = async (
parent: Post,
_args: unknown,
context: Context
) => {
return context.loaders.comments.load(parent.id)
}
Para quem consome a API nada muda, mas agora são 2 requests no lugar de 51.
Cuidado com queries usando aliases #
No GraphQL é o client que escolhe os campos. Isso é muito bom para quem está consumindo a API, mas também é um prato cheio para clients mal intencionados. Usando aliases, uma única request pode repetir o mesmo campo várias vezes, como no exemplo:
query {
a1: post(id: "1") { comments { content } }
a2: post(id: "2") { comments { content } }
a3: post(id: "3") { comments { content } }
# ... e assim por diante até o a1000
}
Cada alias chama de novo o resolver da query e o field resolver. Para o servidor e para o rate limit do proxy isso é uma request só, mas para a API Rest são centenas de chamadas, e dependendo do volume dá para derrubar tanto a API GraphQL quanto as APIs que ela consome. O OWASP chama isso de batching attack, e o DataLoader não ajuda muito aqui, porque os ids podem ser todos diferentes.
O Apollo Server não limita a quantidade de aliases por padrão. Se a sua API é pública, vale colocar um limite, por exemplo com o plugin de max aliases do GraphQL Armor. Esse assunto rende um post só para ele, então por aqui fica só o alerta.
Se você ainda usa o apollo-server v3 #
O pacote apollo-server foi descontinuado e substituído pelo @apollo/server. A diferença que mais pega é a do exemplo acima. Antes o context era uma opção do construtor, como no exemplo:
// apollo-server v2/v3, não funciona mais no @apollo/server 5
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({
loaders: { comments: createCommentsLoader() },
}),
})
Tudo que vimos sobre os resolvers em si (default resolver, parent, resolver map e execução só quando o campo é pedido) funciona igual nas duas versões. O que muda é onde você configura o context e o nome do pacote.
Resumindo #
- Todo campo do schema tem um resolver, e quando você não escreve um, o Apollo lê a propriedade do
parent; - O field resolver serve para preencher um campo que o datasource de cima não trouxe;
- O
parenté o que liga um nível ao outro, é dele que vem oidpara a segunda chamada; - O field resolver só roda quando o campo é pedido na query;
- Quando o campo aparecer dentro de uma lista, use DataLoader para evitar o N+1;
- Como o client escolhe os campos, uma query com muitos aliases multiplica as chamadas dos resolvers. Se a API for pública, coloque um limite.
Tenho aqui um repositório no meu GitHub com o código completo do exemplo funcionando: graphql-field-resolver-example. Ele usa o JSONPlaceholder como API Rest e mostra no terminal cada chamada que o GraphQL faz. Assim dá para ver na prática o resolver de comments rodando só quando o campo é pedido, e o DataLoader juntando os comentários de vários posts em uma chamada só.
Para mais detalhes, você pode dar uma olhada na própria documentação do Apollo Server sobre resolvers.
Obrigado, e bons estudos!