# Spécification OpenAPI de DaloyJS

> Traduction de la page originale en anglais. En cas de divergence, la [version anglaise](/docs/openapi) fait foi.

DaloyJS produit un document **OpenAPI 3.1** propre directement à partir de vos définitions de routes, sans plugins ni décorateurs séparés. La validation, les types et la spécification partagent une seule source de vérité.

**Diagram: Un contrat, quatre sorties**

- **Définition de route** (source, source unique de vérité) - app.get(path, { request, responses }, handler)
- **Validation des requêtes et réponses** (exécution) - Zod · Valibot · ArkType
- **Document OpenAPI 3.1** (spécification) - GET /openapi.json · /openapi.yaml
- **Interface de documentation** (humains) - Scalar · Swagger UI · Redoc
- **SDK client typé** (consommateurs) - génération de code Hey API

Vous écrivez la route une seule fois. La validation, la spécification OpenAPI, la documentation interactive et le client typé en sont tous dérivés : ils ne peuvent donc jamais se désynchroniser.

## Une ligne : montage automatique de /docs, /openapi.json, /openapi.yaml

À la manière de FastAPI. Passez `docs: "auto"` (ou `docs: true`) au constructeur de `App` et DaloyJS enregistre pour vous `GET /openapi.json` + `GET /openapi.yaml` (la spécification en direct dans les deux formats) et `GET /docs` (une interface de référence d’API Scalar).

```ts
import { App } from "@daloyjs/core";

const app = new App({
  openapi: {
    info: { title: "My API", version: "1.0.0" },
    servers: [{ url: "https://api.example.com" }],
    securitySchemes: { bearer: { type: "http", scheme: "bearer" } },
  },
  docs: "auto", // monte GET /docs, GET /openapi.json et GET /openapi.yaml hors production
});
```

`docs: "auto"` ne monte les routes que lorsque l’App n’est pas en production (les modèles `create-daloy` l’utilisent par défaut, pour qu’une API déployée ne publie pas son schéma) ; vous pouvez aussi le laisser désactivé (la valeur par défaut) et monter les routes à la main avec les helpers ci-dessous. Personnalisez chemins, interface et tags via la forme objet. Depuis la 1.5.5, la forme objet se comporte comme `"auto"` sauf si vous définissez `enabled` : personnaliser l’interface ne publie donc jamais par accident votre carte des routes en production. Définissez `enabled: true` (ou `docs: true`) pour une API publique qui doit servir sa documentation en production :

```ts
new App({
  openapi: { info: { title: "My API", version: "1.0.0" } },
  docs: {
    path: "/reference",              // par défaut : "/docs"
    openapiPath: "/spec.json",       // par défaut : "/openapi.json"
    openapiYamlPath: "/spec.yaml",   // par défaut : "/openapi.yaml" ; false le désactive
    ui: "scalar",                     // "scalar" (par défaut) | "swagger" | "redoc"
    scalar: {
      theme: "kepler",
      customCss: ":root { --scalar-color-accent: #2563eb; }",
      hideTestRequestButton: true,
    },
    tags: ["Docs"],                   // par défaut : ["Docs"], passez [] pour l’omettre
    enabled: "auto",                  // par défaut "auto" (désactivé en production) ; true | false
  },
});
```

> **Sécurité :** une spécification publiée est une carte complète de votre surface d’attaque : chaque chemin, paramètre, schéma d’authentification et forme d’erreur. Avec `docs: true` ou `enabled: true` en production, DaloyJS journalise `docs.public_in_production` et sert la spécification à quiconque peut l’atteindre. C’est le bon choix pour une API publique. Pour tout le reste, gardez `"auto"`, ou placez la documentation derrière une authentification (un `bearerAuth()` au niveau de l’application sans `except()` pour `/docs` et `/openapi.json` les couvre). Marquez les routes d’administration, de cron et de débogage `internal: true`. Elles sont alors exclues du document généré et répondent `404` au trafic réseau, tandis que `app.inject()` peut toujours les appeler. Voir [Secure admin panels](/docs/security/admin-panels) (en anglais).

## Choisissez une interface : Scalar, Swagger UI ou Redoc

Réglez `ui` sur `"scalar"` (par défaut), `"swagger"` ou `"redoc"`. Les trois affichent la même spécification en direct, se montent sur les mêmes chemins et livrent la même CSP stricte et les mêmes ressources hébergées sur CDN : changer d’interface tient en un mot. Scalar et Swagger UI incluent une console de requêtes pour les développeurs sur les routes protégées. Redoc est une interface de référence en lecture seule : elle affiche les exigences de sécurité, mais ne fournit pas de console *Try it* intégrée.

```ts
new App({
  openapi: { info: { title: "My API", version: "1.0.0" } },
  docs: {
    ui: "swagger",
    swagger: {
      docExpansion: "none",
      displayRequestDuration: true,
    },
  },
});
```

Par défaut, Swagger UI conserve d’un rechargement à l’autre les autorisations saisies dans sa boîte de dialogue *Authorize*. Il le fait en stockant le jeton collé dans le `localStorage` du navigateur, comme Scalar avec `persistAuth: true`. N’importe quel script exécuté sur l’origine de la documentation peut le lire, et il survit à la fermeture de l’onglet. Ne collez que des jetons de test à courte durée de vie et au moindre privilège, jamais d’identifiants de production ou d’administration, et servez la documentation depuis une origine distincte de toute application web maison. Passez `swagger: { persistAuthorization: false }` pour garder les jetons en mémoire uniquement. Scalar sélectionne automatiquement le premier schéma de sécurité OpenAPI configuré : le formulaire d’authentification est ainsi prêt pour que les développeurs collent un jeton bearer ou une clé d’API. Si vous voulez un autre choix par défaut pour Scalar, définissez-le explicitement :

```ts
new App({
  openapi: {
    info: { title: "My API", version: "1.0.0" },
    securitySchemes: {
      bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
      apiKey: { type: "apiKey", in: "header", name: "x-api-key" },
    },
  },
  docs: {
    scalar: {
      persistAuth: true,
      authentication: { preferredSecurityScheme: "bearer" },
    },
  },
});
```

Si votre documentation a besoin d’un point d’entrée de connexion visible, ajoutez `docs.auth`. Le même bouton est affiché pour Scalar, Swagger UI et Redoc. Faites-le pointer vers un formulaire de connexion local, une route Better Auth, une URL de connexion Clerk ou une URL d’autorisation OIDC d’Auth0, Okta, Microsoft Entra ID, Cognito, Keycloak ou d’un autre fournisseur :

```ts
new App({
  openapi: {
    info: { title: "My API", version: "1.0.0" },
    securitySchemes: {
      oidc: {
        type: "oauth2",
        flows: {
          authorizationCode: {
            authorizationUrl: "https://login.example.com/oauth2/v2.0/authorize",
            tokenUrl: "https://login.example.com/oauth2/v2.0/token",
            scopes: { "api.read": "Read API data" },
          },
        },
      },
    },
  },
  docs: {
    ui: "redoc", // fonctionne aussi avec "scalar" et "swagger"
    auth: {
      loginUrl: "/login", // ou l’URL d’autorisation/de connexion de votre fournisseur
      label: "Sign in",
      target: "popup",
    },
  },
});
```

L’objet `redoc` est transmis tel quel à `Redoc.init(specUrl, configuration, element)`. Comme Redoc construit son index de recherche dans un Web Worker `blob:`, la route `/docs` montée automatiquement élargit d’elle-même la CSP de cette page avec `worker-src 'self' blob:`, pour `ui: "redoc"` uniquement. Scalar et Swagger UI conservent la politique plus stricte. L’option `scalar` est ignorée sauf si `ui` vaut `"scalar"`, et il en va de même pour `swagger` et `redoc`.

L’objet `scalar` est transmis à l’API HTML de Scalar comme configuration JSON, tandis que Daloy garde l’`openapiPath` en direct comme source. Utilisez-le pour les thèmes, le CSS personnalisé, la mise en page, les valeurs d’authentification par défaut et la visibilité des clients, sans copier le HTML de la documentation.

## Avancé : générer la spécification à la main

Besoin de l’objet de spécification brut (pour la génération de code, des tests de contrat ou une route personnalisée) ? Appelez directement `generateOpenAPI(app, options)` :

```ts
import { generateOpenAPI } from "@daloyjs/core/openapi";

const doc = generateOpenAPI(app, {
  info: { title: "My API", version: "1.0.0" },
  servers: [{ url: "https://api.example.com" }],
  securitySchemes: { bearer: { type: "http", scheme: "bearer" } },
});

console.log(JSON.stringify(doc, null, 2));
```

## Avancé : servir la documentation depuis votre propre route

Une route de documentation écrite à la main est une route ordinaire : le filtrage de `docs: "auto"` ne s’y applique donc pas. Décidez vous-même si elle doit exister en production et si elle doit être authentifiée. Il en va de même pour toute route `/openapi.json` que vous servez à la main avec `generateOpenAPI()` :

```ts
import { swaggerUiHtml, scalarHtml, redocHtml, htmlResponse } from "@daloyjs/core/docs";

// Non couvert par `docs: "auto"` : filtrez-le vous-même.
if (process.env.NODE_ENV !== "production") {
  app.get(
    "/docs",
    {
      operationId: "docs",
      responses: { 200: { description: "API reference" } },
    },
    async () => {
      const html = scalarHtml({
        specUrl: "/openapi.json",
        title: "My API",
        configuration: { theme: "kepler" },
      });
      const res = htmlResponse(html);
      return { status: 200, body: await res.text(), headers: Object.fromEntries(res.headers) };
    },
  );
}
```

`swaggerUiHtml`, `scalarHtml` et `redocHtml` renvoient tous des pages HTML autonomes qui chargent leurs ressources depuis jsDelivr, avec une CSP stricte n’autorisant que cette origine. Si vous écrivez la route à la main avec `redocHtml`, passez `allowBlobWorkers: true` à `htmlResponse` (ou à `docsContentSecurityPolicy`) pour que la CSP autorise le worker de recherche `blob:` de Redoc :

```ts
import { redocHtml, htmlResponse } from "@daloyjs/core/docs";

const res = htmlResponse(
  redocHtml({ specUrl: "/openapi.json", title: "My API", configuration: { hideDownloadButtons: true } }),
  { allowBlobWorkers: true },
);
```

Si vous voulez tester l’ergonomie de votre documentation sur un contrat beaucoup plus volumineux, consultez la [grande démo REST factice](/docs/tutorials/fake-rest-api) (en anglais). C’est un meilleur banc d’essai qu’un petit exemple CRUD lorsque vous devez valider la recherche, le regroupement et les performances d’affichage.

## Écrire sur le disque pour la génération de code

```ts
// scripts/dump-openapi.ts
import { writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { generateOpenAPI } from "@daloyjs/core/openapi";
import { buildApp } from "../src/build-app.js";

const app = buildApp();
const out = "./generated/openapi.json";
await mkdir(dirname(out), { recursive: true });
await writeFile(out, JSON.stringify(generateOpenAPI(app, {
  info: { title: "My API", version: "1.0.0" },
}), null, 2));
console.log(`wrote ${out}`);
```

```json
// package.json
"scripts": {
  "gen:openapi": "node scripts/dump-openapi.ts"
}
```

## Ce qui est produit

- Un `operationId` par route ; les doublons lèvent une erreur à l’enregistrement.
- Les paramètres de chemin `:id` normalisés en `{id}`.
- Les corps de schéma convertis via `schema.toJSONSchema?.()` lorsque c’est pris en charge, sinon par un repli structurel.
- Un `components.schemas.Problem` réutilisable pour les erreurs RFC 9457.
- `tags`, `summary`, `description` et la `description` par statut.
- Les routes marquées `internal: true` sont omises, sauf si vous passez `includeInternal: true` (par exemple pour un contrat purement interne destiné à vos propres outils). Ne publiez jamais ce document.

## Webhooks

OpenAPI 3.1 permet à un producteur de publier des **webhooks de premier niveau**, des opérations qu’un consommateur est censé implémenter. Passez `webhooks` à `generateOpenAPI` et DaloyJS les produit sous la table `webhooks` de premier niveau du document.

```ts
import { generateOpenAPI } from "@daloyjs/core/openapi";

const doc = generateOpenAPI(app, {
  info: { title: "Books", version: "1.0.0" },
  webhooks: {
    bookCreated: {
      method: "POST",
      operationId: "onBookCreated",
      summary: "Fires when a book is created",
      tags: ["Webhooks"],
      request: { body: z.object({ id: z.string(), title: z.string() }) },
      responses: { 200: { description: "Acknowledged" } },
      auth: { scheme: "bearer", scopes: ["webhook:receive"] },
    },
  },
});
```

La table `webhooks` ne fait que *décrire* l’opération ; elle ne livre ni ne sécurise rien. Pour de vraies livraisons, signez chaque charge utile pour que les destinataires puissent vérifier à la fois l’origine et l’intégrité, et incluez un horodatage pour qu’ils puissent rejeter les rejeux. Un jeton bearer statique seul ne prouve ni l’un ni l’autre. [`createWebhookSender()`](/docs/webhook-delivery) (en anglais) signe, horodate, réessaie et fait passer chaque requête sortante par `fetchGuard()`. Les destinataires vérifient avec `verifyWebhookSignature()`. Documentez les en-têtes de signature dans la `description` du webhook.

## Callbacks

Les **callbacks** décrivent des requêtes hors bande qu’une opération peut déclencher vers le consommateur (par ex. un endpoint d’abonnement qui envoie plus tard un POST à l’URL fournie par l’appelant). Attachez une table `callbacks` directement à une route ou à un webhook.

```ts
import { fetchGuard } from "@daloyjs/core";

// Chaque requête vers une URL fournie par l’appelant passe par la protection SSRF.
const safeFetch = fetchGuard();

app.post(
  "/subscribe",
  {
    operationId: "subscribe",
    request: {
      body: z
        .object({
          // https uniquement : rejette file:, gopher:, javascript: et http: simple
          callbackUrl: z.url({ protocol: /^https$/ }),
        })
        .strict(),
    },
    responses: { 201: { description: "Subscribed" } },
    callbacks: {
      onEvent: {
        "{$request.body#/callbackUrl}": {
          method: "POST",
          operationId: "onEventCallback",
          request: { body: z.object({ id: z.string() }) },
          responses: {
            200: { description: "ack" },
            410: { description: "gone" },
          },
        },
      },
    },
  },
  async () => ({ status: 201, body: undefined }),
);
```

> **Sécurité :** une URL de callback fournie par l’appelant est un point d’entrée de Server-Side Request Forgery. La validation par schéma ne suffit pas. `z.url()` seul accepte `http://169.254.169.254/` (métadonnées cloud), `http://localhost:6379/` et les URL `file:`/`gopher:`, et même `z.url({ protocol: /^https$/ })` accepte encore `https://169.254.169.254/` ou un nom d’hôte qui se résout en adresse privée. Envoyez chaque callback via `safeFetch` issu de [`fetchGuard()`](/docs/security/fetch-guard) (en anglais), qui vérifie l’adresse résolue et chaque saut de redirection. Mieux encore, livrez avec [`createWebhookSender()`](/docs/webhook-delivery) (en anglais), qui signe aussi la charge utile. Envisagez de vérifier la propriété de l’URL, par exemple avec une requête de défi, avant de l’enregistrer.

Chaque nom de callback correspond à une ou plusieurs clés d’expression d’exécution (par ex. `"{$request.body#/callbackUrl}"`), chacune correspondant à une ou plusieurs opérations indexées par méthode HTTP. Les tables et tableaux vides sont ignorés : passer un callback vide ne produit jamais une spécification malformée.

## Unions discriminées

Le `discriminator` d’OpenAPI 3.1 est la façon canonique de décrire des unions étiquetées. DaloyJS fournit deux helpers dans `@daloyjs/core/openapi` (et dans le paquet racine) :

- `discriminator(propertyName, mapping?)` : le constructeur de spécification brut. Utilisez-le lorsque vous avez déjà un JSON Schema écrit à la main et voulez y attacher proprement le champ.
- `discriminatedUnion(propertyName, variants, opts?)` : une enveloppe compatible Standard Schema qui valide *à la fois* à l’exécution (en aiguillant selon la valeur du discriminant) *et* expose `.toJSONSchema()`, pour que le générateur OpenAPI produise automatiquement `{ oneOf, discriminator }`.

```ts
import { z } from "zod";
import { discriminatedUnion } from "@daloyjs/core";

// .strict() rejette les clés inconnues (par ex. un `isAdmin` injecté) avec un 422
// au lieu de les retirer silencieusement.
const Cat = z.object({ kind: z.literal("cat"), meow: z.boolean() }).strict();
const Dog = z.object({ kind: z.literal("dog"), bark: z.boolean() }).strict();

const Animal = discriminatedUnion(
  "kind",
  { cat: Cat, dog: Dog },
  { mapping: { cat: "#/components/schemas/Cat", dog: "#/components/schemas/Dog" } },
);

app.post(
  "/animals",
  {
    operationId: "createAnimal",
    request: { body: Animal },
    responses: { 201: { description: "ok", body: Animal } },
  },
  async ({ body }) => ({ status: 201, body }),
);
```

À l’exécution, l’enveloppe rejette, avec un problème Standard Schema clair, les valeurs qui ne sont pas des objets, les discriminants manquants ou non textuels et les valeurs de discriminant inconnues (y compris des noms hérités comme `__proto__`, `constructor` et `toString`), puis délègue tout le reste au validateur de la variante correspondante.

Dernière mise à jour : 30 octobre 2026

---

Source: https://daloyjs.dev/fr/docs/openapi