Use Cases (Mis à jour: 02/06/2026)

Développer une API de production avec Claude Code : OpenAPI, Next.js, Zod et CI

Construisez une API de production avec Claude Code : contrat OpenAPI, Route Handlers Next.js, Zod, tests et CI.

Développer une API de production avec Claude Code : OpenAPI, Next.js, Zod et CI

Le piège le plus fréquent avec Claude Code est de demander « un endpoint qui marche » et de s’arrêter là. Pour une démo, c’est séduisant. Pour une API de production, il faut aussi un contrat, une validation d’entrée, une frontière d’authentification, de l’idempotence, du rate limiting, un format d’erreur stable, des logs, des tests et une CI.

Dans ce guide, Claude Code est utilisé comme partenaire de développement API, pas comme simple générateur de code. Le flux est contract-first : on décrit d’abord l’API avec OpenAPI, on implémente ensuite un Route Handler Next.js, on valide avec Zod, puis on transmet le tout avec Vitest et GitHub Actions.

Masa a testé cette méthode sur une petite API de commandes. Quand le prompt disait seulement « crée POST /orders », chaque version changeait la forme des erreurs et le comportement de retry. En ajoutant le contrat, la frontière d’auth, la règle d’idempotence, l’enveloppe d’erreur et les commandes CI, la revue est devenue beaucoup plus factuelle.

Commencer par le contrat OpenAPI

OpenAPI décrit les routes, méthodes, corps de requête, réponses et mécanismes d’authentification d’une API HTTP dans un format lisible par les outils. En clair, c’est la promesse de l’API avant le code. La référence officielle est 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

Le prompt doit dire à Claude Code ce qui est non négociable :

Utilise openapi.yaml comme contrat et implémente POST /api/orders avec Next.js App Router.
Valide requestBody avec Zod. Vérifie Authorization: Bearer <token> dès l’entrée.
Idempotency-Key est obligatoire. Même key et même payload rejouent la même réponse.
Même key avec payload différent retourne 409. Ajoute un rate limit de 60 secondes.
Utilise { error: { code, message, requestId, details } } pour toutes les erreurs.
Ajoute les tests Vitest et le workflow GitHub Actions.

Pour vérifier les choix générés, utilisez les docs officielles de Claude Code et des Next.js Route Handlers.

Fermer la frontière avec Next.js et Zod

La frontière d’API est le point où les données extérieures ne sont pas encore fiables. Un navigateur, une app mobile, un partenaire ou un webhook peut envoyer du JSON invalide, des champs manquants, une ancienne valeur d’énumération ou une requête répétée. Zod est une bibliothèque de validation runtime pour TypeScript ; la documentation officielle est Zod.

Le code suivant peut être copié dans app/api/orders/route.ts. Il utilise Map pour rester exécutable localement. En production, remplacez le stockage des commandes, l’idempotence et le rate limit par une base, Redis ou un 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 });
}

Standardiser les erreurs et l’observabilité

Une enveloppe d’erreur est le format commun à toutes les réponses en échec. Sans format stable, un client ne sait pas s’il doit réessayer, afficher un message ou escalader le problème.

{
  "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)"]
      }
    }
  }
}

L’idempotence signifie qu’une même opération rejouée ne crée pas un second effet de bord. C’est vital pour les commandes, paiements, envois d’e-mails et webhooks. Le rate limit protège l’infrastructure et les quotas. L’observabilité veut simplement dire que l’on peut retrouver ce qui s’est passé grâce au requestId, au nom de l’opération, à l’ID de ressource, à la durée et au code d’erreur. Les tokens, adresses, cartes et données personnelles ne doivent pas être logués.

Trois cas d’usage reviennent souvent : une API de commandes B2B appelée par un back-office et des partenaires, un outil interne d’approbation sensible aux doubles clics, et un récepteur webhook qui doit absorber les retries du fournisseur. Une API publique avec offre gratuite a aussi besoin d’un 429 clair et de logs exploitables.

Tests API et CI comme définition de fini

Si les tests sont repoussés, ils disparaissent souvent. Incluez-les dans la demande Claude Code. Ce test Vitest appelle directement le Route Handler.

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);
  });
});

La CI transforme le contrat en règle de dépôt. Utilisez la syntaxe officielle GitHub Actions et exécutez le lint OpenAPI avec les tests.

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

Pièges, monétisation et résultat

Les pièges sont concrets. OpenAPI et Zod peuvent diverger. Une même clé d’idempotence peut être acceptée avec un payload différent au lieu de retourner 409. Un rate limit en mémoire peut être déployé malgré plusieurs instances. Des erreurs peuvent exposer des détails internes. Les logs peuvent contenir des secrets. Enfin, la CI peut être traitée comme optionnelle.

Pour aller plus loin, lisez l’automatisation des tests API et la stratégie de versioning API. Une équipe qui veut adopter Claude Code côté backend peut passer par la formation et consultation Claude Code. Un développeur solo peut commencer par la fiche gratuite.

À l’essai, ce flux donne un résultat plus maintenable qu’un endpoint généré sans contrat. Le 409 sur conflit d’idempotence, les fieldErrors de Zod, les logs avec requestId et le lint OpenAPI changent la qualité de la revue.

#Claude Code #développement API #OpenAPI #Next.js #Zod #CI
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.