Desenvolvimento de APIs de produção com Claude Code: OpenAPI, Next.js, Zod e CI
Crie APIs de produção com Claude Code: contrato OpenAPI, Route Handlers do Next.js, Zod, testes e CI.
O jeito mais rápido de criar uma API arriscada com Claude Code é pedir “um endpoint que funcione” e parar ali. Para uma demonstração isso pode impressionar. Para produção, faltam contrato, validação, autenticação, idempotência, limite de taxa, formato de erro consistente, logs, testes e CI.
Este guia usa Claude Code como parceiro de desenvolvimento de API, não apenas como gerador de código. O fluxo é contract-first: primeiro definimos a promessa da API com OpenAPI, depois implementamos com Next.js Route Handler, validamos a entrada com Zod e entregamos o resultado com Vitest e GitHub Actions.
Masa testou esse padrão em uma pequena API de pedidos. Quando o prompt só dizia “crie POST /orders”, o formato de erro e o comportamento de retry mudavam a cada tentativa. Ao incluir contrato, fronteira de autenticação, regra de idempotência, envelope de erro e comandos de CI, a revisão ficou muito mais objetiva.
Comece pelo contrato OpenAPI
OpenAPI descreve rotas, métodos, corpo da requisição, respostas e autenticação de uma API HTTP em formato legível por ferramentas. Em termos simples, é a promessa da API antes da implementação. A referência oficial é a OpenAPI Specification.
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/api/orders:
post:
operationId: createOrder
summary: Create an order
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
minLength: 8
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateOrder"
responses:
"201":
description: Order created
"400":
description: Invalid request
"401":
description: Missing or invalid token
"409":
description: Idempotency key reused with another payload
"429":
description: Rate limit exceeded
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
CreateOrder:
type: object
required: [customerId, items, currency]
properties:
customerId:
type: string
minLength: 3
currency:
type: string
enum: [JPY, USD, EUR]
items:
type: array
minItems: 1
O prompt para Claude Code deve deixar claro o que não pode ser quebrado:
Use openapi.yaml como contrato e implemente POST /api/orders com Next.js App Router.
Valide requestBody com Zod. Verifique Authorization: Bearer <token> na fronteira da API.
Idempotency-Key é obrigatório. Mesmo key e mesmo payload devolvem a mesma resposta.
Mesmo key com payload diferente retorna 409. Adicione rate limit de 60 segundos.
Use { error: { code, message, requestId, details } } para todos os erros.
Inclua testes Vitest e um workflow do GitHub Actions.
Durante a revisão, confira as referências oficiais de Claude Code e Next.js Route Handlers.
Feche a fronteira com Next.js e Zod
A fronteira da API é onde dados externos ainda não são confiáveis. Navegadores, apps móveis, integrações de parceiros e webhooks podem enviar JSON inválido, campos ausentes, enums antigos ou requisições duplicadas. Zod faz validação em tempo de execução para TypeScript; a documentação oficial está em Zod.
O código abaixo pode ser copiado para app/api/orders/route.ts. Ele usa Map para rodar localmente sem infraestrutura. Em produção, pedidos, idempotência e rate limit devem ir para banco de dados, Redis ou API Gateway.
import { z } from "zod";
export const runtime = "nodejs";
const CreateOrderSchema = z.object({
customerId: z.string().min(3),
currency: z.enum(["JPY", "USD", "EUR"]),
items: z.array(z.object({
sku: z.string().min(1),
quantity: z.number().int().positive().max(99),
})).min(1),
note: z.string().max(500).optional(),
});
type Order = z.infer<typeof CreateOrderSchema> & {
id: string;
status: "accepted";
createdAt: string;
};
const orders = new Map<string, Order>();
const idempotency = new Map<string, { fingerprint: string; status: number; body: unknown }>();
const buckets = new Map<string, { count: number; resetAt: number }>();
export function __resetForTests() {
orders.clear();
idempotency.clear();
buckets.clear();
}
function send(status: number, body: unknown, headers: Record<string, string> = {}) {
return Response.json(body, { status, headers });
}
function fail(status: number, code: string, message: string, requestId: string, details?: unknown) {
return send(status, { error: { code, message, requestId, ...(details ? { details } : {}) } });
}
function actor(req: Request) {
const expected = process.env.API_TOKEN;
const raw = req.headers.get("authorization") ?? "";
const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
return expected && token === expected ? token.slice(0, 12) : null;
}
function allowed(key: string) {
const now = Date.now();
const current = buckets.get(key);
if (!current || current.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + 60_000 });
return true;
}
if (current.count >= 30) return false;
current.count += 1;
return true;
}
export async function POST(req: Request) {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const who = actor(req);
if (!who) return fail(401, "unauthorized", "Invalid API token.", requestId);
if (!allowed(who)) return fail(429, "rate_limited", "Too many requests.", requestId);
const idempotencyKey = req.headers.get("idempotency-key");
if (!idempotencyKey || idempotencyKey.length < 8) {
return fail(400, "missing_idempotency_key", "Idempotency-Key is required.", requestId);
}
const rawBody = await req.text();
const cacheKey = `${who}:${idempotencyKey}`;
const cached = idempotency.get(cacheKey);
if (cached && cached.fingerprint !== rawBody) {
return fail(409, "idempotency_conflict", "Same key was used with another payload.", requestId);
}
if (cached) return send(cached.status, cached.body, { "x-idempotent-replay": "true" });
let payload: unknown;
try {
payload = JSON.parse(rawBody);
} catch {
return fail(400, "invalid_json", "Request body must be JSON.", requestId);
}
const parsed = CreateOrderSchema.safeParse(payload);
if (!parsed.success) {
return fail(400, "validation_failed", "Request does not match the contract.", requestId, parsed.error.flatten());
}
const order: Order = { ...parsed.data, id: crypto.randomUUID(), status: "accepted", createdAt: new Date().toISOString() };
orders.set(order.id, order);
const body = { data: order, meta: { requestId } };
idempotency.set(cacheKey, { fingerprint: rawBody, status: 201, body });
console.info("orders.create", { requestId, orderId: order.id, itemCount: order.items.length });
return send(201, body, { "x-request-id": requestId });
}
Padronize erro, idempotência e observabilidade
Um envelope de erro é o formato comum de todas as respostas com falha. Sem isso, clientes não sabem se devem tentar novamente, mostrar mensagem ou abrir chamado. O exemplo retorna:
{
"error": {
"code": "validation_failed",
"message": "Request does not match the contract.",
"requestId": "6f0c9c0f-6db7-4bdf-930b-7cc7d13f3f77",
"details": {
"fieldErrors": {
"items": ["Array must contain at least 1 element(s)"]
}
}
}
}
Idempotência significa que reenviar a mesma operação não gera um segundo efeito colateral. Isso é essencial para pedidos, pagamentos, e-mails, créditos e webhooks. Rate limit protege infraestrutura e custo. Observabilidade significa conseguir entender depois o que aconteceu usando requestId, nome da operação, ID do recurso, duração e código de erro. Tokens, endereços, cartões e dados pessoais não devem ir para logs.
Casos de uso: API de pedidos B2B chamada por painel interno e parceiros; ferramenta de aprovação que não pode aprovar duas vezes por duplo clique; receptor de webhook que precisa lidar com retries; API pública com plano gratuito e resposta 429 previsível.
Testes de API e CI como entrega
Testes não devem ficar para depois. Peça a Claude Code para gerá-los no mesmo trabalho. Este Vitest chama o Route Handler diretamente.
import { beforeEach, describe, expect, it } from "vitest";
import { POST, __resetForTests } from "../app/api/orders/route";
const validOrder = {
customerId: "cus_123",
currency: "JPY",
items: [{ sku: "book-001", quantity: 2 }],
};
function req(body: unknown, headers: Record<string, string> = {}) {
return new Request("http://localhost/api/orders", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer test-token",
"idempotency-key": crypto.randomUUID(),
...headers,
},
body: JSON.stringify(body),
});
}
describe("POST /api/orders", () => {
beforeEach(() => {
process.env.API_TOKEN = "test-token";
__resetForTests();
});
it("creates an order", async () => {
const res = await POST(req(validOrder));
expect(res.status).toBe(201);
expect((await res.json()).data.status).toBe("accepted");
});
it("rejects invalid input", async () => {
const res = await POST(req({ ...validOrder, items: [] }));
expect(res.status).toBe(400);
expect((await res.json()).error.code).toBe("validation_failed");
});
it("returns 409 for conflicting idempotency reuse", async () => {
const key = "order-key-001";
await POST(req(validOrder, { "idempotency-key": key }));
const res = await POST(req({ ...validOrder, currency: "USD" }, { "idempotency-key": key }));
expect(res.status).toBe(409);
});
});
CI transforma o contrato em regra do repositório. Use a sintaxe oficial de GitHub Actions e rode lint de OpenAPI junto dos testes.
name: api-contract
on:
pull_request:
paths:
- "app/api/**"
- "tests/**/*.route.test.ts"
- "openapi.yaml"
jobs:
api-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx @redocly/cli lint openapi.yaml
- run: npx vitest run tests/**/*.route.test.ts
Armadilhas, CTA e resultado prático
As armadilhas são concretas. OpenAPI e Zod podem ficar divergentes. O mesmo idempotency key pode ser aceito com payload diferente em vez de devolver 409. Um rate limit em memória pode ir para produção com várias instâncias. Erros podem expor detalhes internos. Logs podem guardar tokens ou dados pessoais. E a CI pode virar uma tarefa opcional, quando deveria ser parte da entrega.
Para aprofundar, leia automação de testes de API e estratégia de versionamento de API. Equipes que querem adotar Claude Code no backend podem seguir para treinamento e consultoria. Desenvolvedores individuais podem começar pelo cheatsheet gratuito.
No teste prático, esse fluxo foi mais fácil de revisar do que um endpoint criado sem contrato. A regra 409, os fieldErrors do Zod, logs com requestId e lint de OpenAPI transformam a saída de Claude Code em um ponto de partida sustentável.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.