Protocolos & APIs

GraphQL

Referência completa de GraphQL — schema, queries, mutations, subscriptions, resolvers, N+1, DataLoader, autenticação e ferramentas

Fundamentos GraphQL

GraphQL é uma linguagem de consulta para APIs criada pelo Facebook em 2012. Diferente do REST, o cliente especifica exatamente quais campos quer — eliminando overfetching (dados demais) e underfetching (dados de menos que forçam múltiplas requisições).

AspectoRESTGraphQL
EndpointsMúltiplos (/users, /posts)Único (/graphql)
Forma dos dadosDefinida pelo servidorDefinida pelo cliente
OverfetchingComumEliminado
Underfetching / N requisiçõesComumResolvido com nested queries
Versionamento/v1/, /v2/Schema evolui via deprecation
CachingHTTP cache nativoMais complexo (requer APQ ou campo id)
Type systemInformal (OpenAPI opcional)Fortemente tipado (SDL obrigatório)
StreamingVia SSE/WebSocket manualSubscriptions nativo
Learning curveBaixaMédia-alta

Quando usar GraphQL:

  • Apps com múltiplos clientes (web, mobile) com necessidades diferentes
  • Dados altamente relacionados (grafos de entidades)
  • Produto evoluindo rapidamente (evita versionar endpoints)
  • Times frontend com autonomia para compor queries

Quando preferir REST:

  • APIs públicas simples ou de terceiros
  • Upload de arquivos, streaming binário
  • Caching HTTP crítico para performance
  • Time pequeno sem expertise em GraphQL

Schema Definition Language (SDL)

O schema é o contrato da API — define todos os tipos, campos e operações disponíveis. É a única fonte de verdade.

# Scalars built-in: String, Int, Float, Boolean, ID
# Scalar customizado
scalar DateTime
scalar Upload

# Object type
type User {
  id:        ID!         # ! = non-null (obrigatório)
  name:      String!
  email:     String!
  role:      Role!
  posts:     [Post!]!    # lista non-null de items non-null
  createdAt: DateTime!
  deletedAt: DateTime    # nullable = pode ser null
}

# Enum
enum Role {
  ADMIN
  EDITOR
  VIEWER
}

# Interface — contrato que tipos devem implementar
interface Node {
  id: ID!
}

interface Timestamped {
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Post implements Node & Timestamped {
  id:        ID!
  title:     String!
  body:      String!
  author:    User!
  tags:      [String!]!
  published: Boolean!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# Union — campo pode ser um de vários tipos (sem campos em comum)
union SearchResult = User | Post | Comment

# Input type — usado em argumentos de mutations
input CreatePostInput {
  title:     String!
  body:      String!
  tags:      [String!]
  published: Boolean
}

input PostFilters {
  authorId:  ID
  published: Boolean
  tags:      [String!]
  createdAfter: DateTime
}

# Directives
type Product {
  id:       ID!
  name:     String!
  oldPrice: Float @deprecated(reason: "Use priceV2")
  priceV2:  Float!
}

# Entry points — obrigatório ter pelo menos Query
type Query {
  user(id: ID!): User
  users(filters: UserFilters, limit: Int = 20, offset: Int = 0): [User!]!
  post(id: ID!): Post
  search(query: String!): [SearchResult!]!
  node(id: ID!): Node       # Relay global ID pattern
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
  deletePost(id: ID!): DeletePostPayload!
}

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

Queries

O cliente descreve exatamente o shape dos dados que precisa.

# Query simples
query GetUser {
  user(id: "usr-1") {
    id
    name
    email
  }
}

# Aliases — múltiplos campos do mesmo tipo
query GetTwoUsers {
  author:  user(id: "usr-1") { name email }
  reviewer: user(id: "usr-2") { name email }
}

# Fragments — reutilizar seleção de campos
fragment UserFields on User {
  id
  name
  email
  role
}

query GetUsers {
  users {
    ...UserFields
    posts { id title }
  }
}

# Inline fragments — polimorfismo em unions/interfaces
query Search {
  search(query: "graphql") {
    __typename
    ... on User { id name email }
    ... on Post { id title author { name } }
    ... on Comment { id body post { title } }
  }
}

# Variáveis — nunca interpolar strings na query
query GetPost($id: ID!, $withAuthor: Boolean = true) {
  post(id: $id) {
    id
    title
    body
    author @include(if: $withAuthor) {
      name
      email
    }
  }
}
# Variáveis enviadas no JSON separado:
# { "id": "post-123", "withAuthor": true }

# Directives @skip e @include
query GetPosts($showDraft: Boolean!) {
  posts {
    id
    title
    draft @include(if: $showDraft)
    publishedAt @skip(if: $showDraft)
  }
}

# Introspection — autodocumentação
query IntrospectSchema {
  __schema {
    types { name kind description }
    queryType { name }
    mutationType { name }
  }
}

query IntrospectType {
  __type(name: "User") {
    name
    fields {
      name
      type { name kind ofType { name kind } }
      description
      isDeprecated
      deprecationReason
    }
  }
}

Mutations

Mutations modificam estado no servidor. Sempre retornam o objeto modificado para o cliente atualizar seu cache sem nova requisição.

# Mutation básica
mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    post {
      id
      title
      author { name }
    }
    errors {
      field
      message
    }
  }
}

# Variáveis:
# {
#   "input": {
#     "title": "GraphQL é incrível",
#     "body": "...",
#     "tags": ["graphql", "api"],
#     "published": false
#   }
# }
# Payload type pattern (Shopify style) — union result para erros tipados
type CreatePostPayload {
  post:   Post
  errors: [UserError!]!
}

type UserError {
  field:   [String!]!   # caminho do campo com erro: ["input", "title"]
  message: String!
}

# Resultado de sucesso:
# { "data": { "createPost": { "post": {...}, "errors": [] } } }
# Resultado de erro:
# { "data": { "createPost": { "post": null, "errors": [{ "field": ["input", "title"], "message": "Título muito curto" }] } } }
// Múltiplas mutations em sequência são garantidamente seriais (não paralelas)
// Para atomicidade, implemente transação no resolver
const createPostResolver = async (_, { input }, { db }) => {
  return db.transaction(async (trx) => {
    const post = await trx.post.create({ data: input });
    await trx.activityLog.create({ data: { action: "POST_CREATED", postId: post.id } });
    return { post, errors: [] };
  });
};

Subscriptions

Subscriptions entregam dados em tempo real via WebSocket. Use quando o cliente precisa ser notificado de eventos sem polling.

# Definição no schema
type Subscription {
  commentAdded(postId: ID!): Comment!
  postPublished: Post!
  userOnlineStatus(userId: ID!): UserStatus!
}

# Uso no cliente
subscription OnCommentAdded($postId: ID!) {
  commentAdded(postId: $postId) {
    id
    body
    author { name avatar }
    createdAt
  }
}
// Apollo Server 4 + graphql-ws
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import { PubSub } from "graphql-subscriptions";

const pubsub = new PubSub();
const COMMENT_ADDED = "COMMENT_ADDED";

const resolvers = {
  Mutation: {
    addComment: async (_, { postId, body }, { db, user }) => {
      const comment = await db.comment.create({ data: { postId, body, authorId: user.id } });
      pubsub.publish(COMMENT_ADDED, { commentAdded: comment, postId });
      return { comment, errors: [] };
    },
  },
  Subscription: {
    commentAdded: {
      subscribe: (_, { postId }) =>
        pubsub.asyncIterableIterator([COMMENT_ADDED]),
      resolve: (payload) => payload.commentAdded,
      // Filtrar por postId
      filter: (payload, variables) => payload.postId === variables.postId,
    },
  },
};

const schema = makeExecutableSchema({ typeDefs, resolvers });
const wsServer = new WebSocketServer({ server: httpServer, path: "/graphql" });
useServer({ schema }, wsServer);

Resolvers

Cada campo do schema pode ter um resolver — função que retorna o valor do campo. Se não houver resolver explícito, o default resolver retorna parent[fieldName].

// Assinatura: (root/parent, args, context, info)
const resolvers = {
  Query: {
    // root = undefined para Query raiz
    user: async (_, { id }, { db, user: currentUser }) => {
      if (!currentUser) throw new GraphQLError("Não autenticado", { extensions: { code: "UNAUTHENTICATED" } });
      return db.user.findUnique({ where: { id } });
    },

    users: async (_, { filters, limit = 20, offset = 0 }, { db }) => {
      return db.user.findMany({
        where: buildWhereClause(filters),
        take: limit,
        skip: offset,
      });
    },
  },

  Mutation: {
    createPost: async (_, { input }, { db, user }) => {
      if (!user) throw new GraphQLError("Não autenticado", { extensions: { code: "UNAUTHENTICATED" } });
      try {
        const post = await db.post.create({ data: { ...input, authorId: user.id } });
        return { post, errors: [] };
      } catch (err) {
        if (err.code === "P2002") {
          return { post: null, errors: [{ field: ["input", "title"], message: "Título já existe" }] };
        }
        throw err;
      }
    },
  },

  // Resolver de tipo — executado para cada instância de User
  User: {
    posts: async (user, { limit = 10 }, { loaders }) => {
      // usar DataLoader — não db.post.findMany direto (N+1!)
      return loaders.postsByAuthor.load(user.id);
    },

    // Default resolver equivalente (não precisa escrever):
    // name: (user) => user.name,
  },

  // Union resolver — determina o tipo concreto
  SearchResult: {
    __resolveType(obj) {
      if (obj.email)  return "User";
      if (obj.title)  return "Post";
      if (obj.body)   return "Comment";
      return null;
    },
  },
};

// Context builder — executado a cada requisição
const context = async ({ req }) => {
  const token = req.headers.authorization?.replace("Bearer ", "");
  const user  = token ? await verifyToken(token) : null;
  return {
    user,
    db: prisma,
    loaders: createLoaders(prisma), // per-request DataLoaders
  };
};

N+1 Problem e DataLoader

O N+1 é o problema mais crítico em GraphQL. Ao buscar uma lista de posts com seus autores, sem DataLoader cada post dispara uma query separada para buscar o autor.

// ❌ SEM DataLoader — N+1: 1 query para posts + N queries para autores
const resolvers = {
  Post: {
    author: async (post, _, { db }) => {
      return db.user.findUnique({ where: { id: post.authorId } }); // executa para CADA post
    },
  },
};
// Para 100 posts → 101 queries no banco!

// ✅ COM DataLoader — 1 query para posts + 1 batch query para todos os autores
import DataLoader from "dataloader";

// Função de batch: recebe array de IDs, retorna array de resultados NA MESMA ORDEM
function createUserLoader(db) {
  return new DataLoader(async (userIds) => {
    const users = await db.user.findMany({ where: { id: { in: [...userIds] } } });
    const userMap = new Map(users.map(u => [u.id, u]));
    return userIds.map(id => userMap.get(id) ?? null); // MESMA ORDEM dos IDs
  });
}

// Resolver usando DataLoader
const resolvers = {
  Post: {
    author: (post, _, { loaders }) => loaders.user.load(post.authorId),
  },
};

// Context builder — criar DataLoaders POR REQUEST (não compartilhar entre requests!)
const context = ({ req }) => ({
  db: prisma,
  loaders: {
    user:          createUserLoader(prisma),
    postsByAuthor: createPostsByAuthorLoader(prisma),
  },
});

// DataLoader para relacionamento 1:N (posts por autor)
function createPostsByAuthorLoader(db) {
  return new DataLoader(async (authorIds) => {
    const posts = await db.post.findMany({ where: { authorId: { in: [...authorIds] } } });
    return authorIds.map(id => posts.filter(p => p.authorId === id));
  });
}

Schema Design

Boas convenções de schema evitam refactorings dolorosos depois.

# ── Paginação estilo Relay Connection ───────────────────────────
# Padrão para listas paginadas — permite cursor-based pagination
type PostConnection {
  edges:    [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node:   Post!
  cursor: String!
}

type PageInfo {
  hasNextPage:     Boolean!
  hasPreviousPage: Boolean!
  startCursor:     String
  endCursor:       String
}

type Query {
  posts(
    first:  Int
    after:  String
    last:   Int
    before: String
    filter: PostFilters
    orderBy: PostOrderBy
  ): PostConnection!
}

# Uso:
# query { posts(first: 10, after: "cursor123") { edges { node { id title } cursor } pageInfo { hasNextPage endCursor } } }

# ── Filtros e ordenação tipados ─────────────────────────────────
input PostFilters {
  published:    Boolean
  authorId:     ID
  tags:         [String!]
  createdAfter: DateTime
  search:       String
}

enum PostOrderField {
  CREATED_AT
  TITLE
  VIEW_COUNT
}

enum SortDirection { ASC DESC }

input PostOrderBy {
  field:     PostOrderField!
  direction: SortDirection!
}

# ── Naming conventions ─────────────────────────────────────────
# Tipos:     PascalCase     → User, BlogPost, OrderItem
# Campos:    camelCase      → firstName, createdAt, isPublished
# Enums:     SCREAMING_SNAKE → PENDING, IN_PROGRESS, COMPLETED
# Mutations: verbo + objeto → createUser, updatePost, deleteComment, publishPost
# Queries:   substantivo    → user, users, post, myPosts

Autenticação e Autorização

Auth no GraphQL vai no context — disponível em todos os resolvers. Autorização por campo é mais granular que por endpoint.

import { GraphQLError } from "graphql";

// ── Context com JWT ────────────────────────────────────────────
const context = async ({ req }) => {
  const token = req.headers.authorization?.replace("Bearer ", "");
  let currentUser = null;
  if (token) {
    try { currentUser = jwt.verify(token, process.env.JWT_SECRET); }
    catch { /* token inválido — usuário permanece null */ }
  }
  return { currentUser, db: prisma, loaders: createLoaders(prisma) };
};

// ── Helper de auth ────────────────────────────────────────────
function requireAuth(user) {
  if (!user) throw new GraphQLError("Autenticação necessária", {
    extensions: { code: "UNAUTHENTICATED" },
  });
}

function requireRole(user, role) {
  requireAuth(user);
  if (user.role !== role) throw new GraphQLError("Permissão negada", {
    extensions: { code: "FORBIDDEN" },
  });
}

// ── Autorização nos resolvers ──────────────────────────────────
const resolvers = {
  Query: {
    adminStats: (_, __, { currentUser }) => {
      requireRole(currentUser, "ADMIN");
      return getAdminStats();
    },
    myPosts: (_, __, { currentUser, db }) => {
      requireAuth(currentUser);
      return db.post.findMany({ where: { authorId: currentUser.id } });
    },
  },

  // Por campo — email só visível para o próprio usuário ou admin
  User: {
    email: (user, _, { currentUser }) => {
      if (!currentUser) return null;
      if (currentUser.id === user.id || currentUser.role === "ADMIN") return user.email;
      return null;
    },
  },
};

// ── graphql-shield — autorização declarativa ───────────────────
import { shield, rule, and, or, not } from "graphql-shield";

const isAuthenticated = rule()((_, __, { currentUser }) => currentUser !== null);
const isAdmin         = rule()((_, __, { currentUser }) => currentUser?.role === "ADMIN");
const isOwner         = rule()(async (post, _, { currentUser }) => post.authorId === currentUser?.id);

export const permissions = shield({
  Query:    { adminStats: isAdmin, myPosts: isAuthenticated },
  Mutation: {
    createPost: isAuthenticated,
    deletePost: or(isAdmin, isOwner),
  },
});

Performance e Segurança

import depthLimit      from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-validation-complexity";

const server = new ApolloServer({
  schema,
  validationRules: [
    depthLimit(7),                                    // máximo 7 níveis de nesting
    createComplexityLimitRule(1000, {               // score máximo por query
      scalarCost: 1, objectCost: 2, listFactor: 10, // listas custam mais
    }),
  ],

  // Desabilitar introspection em produção
  introspection: process.env.NODE_ENV !== "production",

  // Persisted Queries (APQ) — hash da query ao invés da query completa
  // Reduz bandwidth e permite caching HTTP
  cache: new InMemoryLRUCache({ maxSize: Math.pow(2, 20) * 30 }), // 30 MB
});

// @cacheControl por campo (Apollo Server)
// SDL:
// type Post { id: ID! title: String! @cacheControl(maxAge: 300) }
// type Query { post(id: ID!): Post @cacheControl(maxAge: 60, scope: PUBLIC) }

// Rate limiting por operação
app.use("/graphql", rateLimit({
  windowMs: 60 * 1000,
  max: (req) => {
    // Operações de leitura têm limite mais alto
    const body = req.body;
    if (body?.operationName?.startsWith("Get")) return 200;
    return 50; // mutations têm limite baixo
  },
  message: { errors: [{ message: "Muitas requisições — tente novamente em 1 minuto" }] },
}));

Apollo Server (Node.js)

// Apollo Server 4 — setup moderno com Express
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import express from "express";
import cors from "cors";
import { json } from "body-parser";

const typeDefs = `#graphql
  type Query { hello: String! }
`;

const resolvers = {
  Query: { hello: () => "Olá, mundo!" },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,

  // Formatar erros — remover stack trace em produção
  formatError: (formattedError, error) => {
    if (process.env.NODE_ENV === "production") {
      const { message, locations, path, extensions } = formattedError;
      return { message, locations, path, extensions: { code: extensions?.code } };
    }
    return formattedError;
  },

  // Plugins — lifecycle hooks
  plugins: [
    {
      requestDidStart: async () => ({
        didResolveOperation: async ({ request, document }) => {
          console.log("Operation:", request.operationName);
        },
        didEncounterErrors: async ({ errors }) => {
          errors.forEach(err => logger.error(err));
        },
      }),
    },
  ],
});

await server.start();

const app = express();
app.use("/graphql", cors(), json(), expressMiddleware(server, {
  context: async ({ req }) => buildContext(req),
}));

// Schema-first vs Code-first:
// Schema-first: escreve SDL manualmente, mais legível, melhor para equipes
// Code-first (Pothos, TypeGraphQL, Nexus): gera SDL a partir do código TS, mais type-safe

Ferramentas

# GraphiQL — IDE no browser (embutido no Apollo Server em dev)
# Apollo Sandbox — https://studio.apollographql.com/sandbox

# graphql-code-generator — gera tipos TypeScript do schema
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

# codegen.yml
# schema: ./src/schema.graphql
# generates:
#   src/__generated__/types.ts:
#     plugins:
#       - typescript
#       - typescript-resolvers
#     config:
#       contextType: ../context#Context
#       useIndexSignature: true
npx graphql-codegen

# Apollo Client — consulta com tipos gerados
import { gql, useQuery } from "@apollo/client";
import { GetUserQuery, GetUserQueryVariables } from "./__generated__/types";

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) { id name email posts { id title } }
  }
`;

const { data, loading, error } = useQuery<GetUserQuery, GetUserQueryVariables>(
  GET_USER, { variables: { id: "usr-1" } }
);

# Testing com executeOperation (sem HTTP)
const { body } = await server.executeOperation({
  query: `query { user(id: "usr-1") { name email } }`,
});
expect(body.singleResult.data?.user?.name).toBe("Ana");

# Rover CLI — Federated GraphQL (Apollo Federation)
npm install -g @apollo/rover
rover graph introspect http://localhost:4000/graphql

Comparativo: GraphQL vs REST vs gRPC

CritérioRESTGraphQLgRPC
TransportHTTP/1.1+HTTP/1.1+HTTP/2
FormatoJSONJSONProtobuf (binário)
TipagemOpenAPI (opcional)SDL (obrigatório)Proto (obrigatório)
OverfetchingComumEliminadoN/A (campos fixos)
StreamingSSE / WebSocket manualSubscriptionsNativo (4 modos)
Browser✅ Nativo✅ Nativo⚠️ Requer grpc-web
Caching✅ HTTP cache⚠️ Requer APQ❌ Manual
Code genOpcionalOpcionalObrigatório
Use case idealAPIs públicas, CRUD simplesProduto com múltiplos clientesMicroservices internos