Skip to content

Search docs

Jump between documentation pages.

Browse docs

DaloyJS OpenAPI spec

DaloyJS emits a clean OpenAPI 3.1 document straight from your route definitions, no plugins, no separate decorators. Validation, types, and the spec all share one source of truth.

One contract, four outputs
single source of truthRoute definitionapp.get(path, { request, responses }, handler)
runtimeRequest & response validationZod · Valibot · ArkType
specOpenAPI 3.1 documentGET /openapi.json · /openapi.yaml
humansDocs UIScalar · Swagger UI · Redoc
consumersTyped client SDKHey API codegen
You write the route once. Validation, the OpenAPI spec, the interactive docs, and the typed client are all derived from it, so they can never drift out of sync.

One line: auto-mount /docs, /openapi.json, /openapi.yaml

FastAPI-style. Pass docs: "auto" (or docs: true) to the App constructor and DaloyJS registers GET /openapi.json + GET /openapi.yaml (the live spec in both formats) and GET /docs (a Scalar API reference UI) for you.

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", // mounts GET /docs, GET /openapi.json, GET /openapi.yaml outside production
});

docs: "auto" mounts only when the App is not in production (the create-daloy templates default to this, so a deployed API does not publish its schema), or leave it off (the default) and mount manually with the helpers below. Customize paths, UI, and tags via the object form. Since 1.5.5 the object form behaves like "auto" unless you set enabled, so customizing the UI never publishes your route map in production by accident; set enabled: true (or docs: true) for a public API that should serve its docs in production:

ts
new App({
  openapi: { info: { title: "My API", version: "1.0.0" } },
  docs: {
    path: "/reference",              // default: "/docs"
    openapiPath: "/spec.json",       // default: "/openapi.json"
    openapiYamlPath: "/spec.yaml",   // default: "/openapi.yaml"; false disables it
    ui: "scalar",                     // "scalar" (default) | "swagger" | "redoc"
    scalar: {
      theme: "kepler",
      customCss: ":root { --scalar-color-accent: #2563eb; }",
      hideTestRequestButton: true,
    },
    tags: ["Docs"],                   // default: ["Docs"], pass [] to omit
    enabled: "auto",                  // default "auto" (off in production); true | false
  },
});
Security: a published spec is a complete map of your attack surface: every path, parameter, auth scheme, and error shape. With docs: true or enabled: true in production, DaloyJS logs docs.public_in_production and serves the spec to anyone who can reach it. That is right for a public API. For anything else, keep "auto", or put the docs behind authentication (an app-level bearerAuth() without an except() for /docs and /openapi.json covers them). Mark admin, cron, and debugging routes internal: true. They are then left out of the generated document and answer 404 to network traffic, while app.inject() can still call them. See Secure admin panels.

Pick a UI: Scalar, Swagger UI, or Redoc

Set ui to "scalar" (default), "swagger", or "redoc". All three render the same live spec, mount on the same paths, and ship the same strict CSP and CDN-hosted assets, so switching is a one-word change. Scalar and Swagger UI include a developer request console for protected routes. Redoc is a read-only reference UI: it displays security requirements, but it does not ship a built-in Try it console.

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

Swagger UI keeps authorizations from its Authorize dialog across reloads by default. It does this by storing the pasted token in the browser's localStorage, as does Scalar with persistAuth: true. Any script running on the docs origin can read it, and it survives closing the tab. Paste short-lived, least-privilege test tokens only, never production or admin credentials, and serve docs from a separate origin from any first-party web app. Pass swagger: { persistAuthorization: false } to keep tokens in memory only. Scalar automatically selects the first configured OpenAPI security scheme, so the auth form is ready for developers to paste a bearer token or API key. If you want a different Scalar default, set it explicitly:

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

If your docs need a visible login entry point, add docs.auth. The same button is rendered for Scalar, Swagger UI, and Redoc. Point it at a local login form, Better Auth route, Clerk sign-in URL, or an OIDC authorization URL from Auth0, Okta, Microsoft Entra ID, Cognito, Keycloak, or another provider:

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", // also works with "scalar" and "swagger"
    auth: {
      loginUrl: "/login", // or your provider's authorize/sign-in URL
      label: "Sign in",
      target: "popup",
    },
  },
});

The redoc object is forwarded verbatim to Redoc.init(specUrl, configuration, element). Because Redoc builds its search index in a blob: Web Worker, the auto-mounted /docs route automatically widens that page's CSP with worker-src 'self' blob: for ui: "redoc" only. Scalar and Swagger UI keep the tighter policy. The scalar option is ignored unless ui is "scalar", and likewise for swagger and redoc.

The scalar object is forwarded to Scalar's HTML API as JSON configuration while Daloy keeps the live openapiPath as the source. Use it for themes, custom CSS, layout, auth defaults, and client visibility without copying the docs HTML.

Advanced: generate the spec manually

Need the raw spec object (for codegen, contract tests, or a custom route)? Call generateOpenAPI(app, options) directly:

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

Advanced: serve docs from your own route

A hand-rolled docs route is an ordinary route, so docs: "auto" gating does not apply to it. Decide yourself whether it should exist in production and whether it needs auth. The same applies to any /openapi.json route you serve by hand with generateOpenAPI():

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

// Not covered by `docs: "auto"`, so gate it yourself.
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, and redocHtml all return self-contained HTML pages that load their assets from jsDelivr with a strict CSP allowing only that origin. When you hand-roll the route with redocHtml, pass allowBlobWorkers: true to htmlResponse (or docsContentSecurityPolicy) so Redoc's blob: search worker is allowed by the CSP:

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

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

If you want to test your docs UX against a much larger contract, see the large fake REST demo. It is a better benchmark than a toy CRUD sample when you need to validate search, grouping, and render performance.

Dump to disk for codegen

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"
}

What gets emitted

  • One operationId per route, duplicates throw at registration.
  • Path params :id normalized to {id}.
  • Schema bodies converted via schema.toJSONSchema?.() when supported, or a structural fallback.
  • Reusable components.schemas.Problem for RFC 9457 errors.
  • Schemas tagged with an id (.meta({ id: "Book" })) are hoisted into components.schemas and referenced by $ref (see Named schemas).
  • tags, summary, description, and per-status description.
  • Routes marked internal: true are omitted unless you pass includeInternal: true (for example, an internal-only contract for your own tooling). Never publish that document.

Named schemas and components.schemas

By default every request and response schema is inlined into the operation that uses it. That is correct, but a generated client then names the types after the operation (GetBookResponse, ListBooksResponse[number]), and the same shape is repeated in every operation that returns it.

Give a schema an id and DaloyJS emits it once under components.schemas, referencing it by $ref everywhere it appears, including inside other named schemas, path and query parameter schemas, webhooks, and callbacks:

ts
import { z } from "zod";

export const Book = z
  .object({ id: z.string(), title: z.string() })
  .strict()
  .meta({ id: "Book" });

export const Author = z
  .object({ name: z.string(), books: z.array(Book) })
  .strict()
  .meta({ id: "Author" });

app.get("/books/:id", { responses: { 200: { body: Book } } }, getBook);
app.get("/authors", { responses: { 200: { body: z.array(Author) } } }, listAuthors);
json
{
  "paths": {
    "/books/{id}": { "get": { "responses": { "200": { "content": { "application/json": {
      "schema": { "$ref": "#/components/schemas/Book" } } } } } } },
    "/authors": { "get": { "responses": { "200": { "content": { "application/json": {
      "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Author" } } } } } } } }
  },
  "components": {
    "schemas": {
      "Problem": { "...": "..." },
      "Book": { "type": "object", "properties": { "id": { "type": "string" }, "title": { "type": "string" } }, "required": ["id", "title"], "additionalProperties": false },
      "Author": { "type": "object", "properties": { "name": { "type": "string" }, "books": { "type": "array", "items": { "$ref": "#/components/schemas/Book" } } }, "required": ["name", "books"], "additionalProperties": false }
    }
  }
}

Hey API (and every other OpenAPI client generator) now produces a Book and an Author type you can import directly, instead of a per-operation anonymous shape, so there is no need to hand-maintain a models.ts that renames generated types.

Rules:

  • Ids must be unique per shape. Two structurally different schemas sharing one id make generateOpenAPI() throw, because the second would silently replace the first in every generated client. Reuse the same schema instance, or give the second one its own id.
  • Problem is reserved for the built-in RFC 9457 error schema.
  • Zod 4's own $defs / $schema keys never reach the document: the dialect is already set document-wide by OpenAPI 3.1, and #/$defs/... pointers are rewritten to #/components/schemas/... so no reference dangles.
  • Schemas without an id keep being inlined, exactly as before.

Zod is the reference implementation here (.meta({ id }) is what makes toJSONSchema() emit $defs). Any Standard Schema library whose toJSONSchema() returns the same { $ref, $defs } shape is hoisted the same way.

Webhooks

OpenAPI 3.1 lets a producer publish top-level webhooks, operations a consumer is expected to implement. Pass webhooks to generateOpenAPI and DaloyJS emits them under the document's top-level webhooks map.

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

The webhooks map only describes the operation; it does not deliver or secure anything. For real deliveries, sign each payload so receivers can verify both origin and integrity, and include a timestamp so they can reject replays. A static bearer token alone proves neither. createWebhookSender() signs, timestamps, retries, and routes every outbound request through fetchGuard(). Receivers verify with verifyWebhookSignature(). Document the signature headers in the webhook's description.

Callbacks

Callbacks describe out-of-band requests that an operation may trigger on the consumer (e.g. a subscription endpoint that later POSTs to the URL the caller supplied). Attach a callbacks map directly to a route or webhook.

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

// Every request to a caller-supplied URL goes through the SSRF guard.
const safeFetch = fetchGuard();

app.post(
  "/subscribe",
  {
    operationId: "subscribe",
    request: {
      body: z
        .object({
          // https only: rejects file:, gopher:, javascript:, and plain http:
          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 }),
);
Security: a caller-supplied callback URL is a Server-Side Request Forgery sink. Schema validation is not enough. z.url() on its own accepts http://169.254.169.254/ (cloud metadata), http://localhost:6379/, and file:/gopher: URLs, and even z.url({ protocol: /^https$/ }) still accepts https://169.254.169.254/ or a hostname that resolves to a private address. Send every callback through safeFetch from fetchGuard(), which checks the resolved address and every redirect hop. Better still, deliver with createWebhookSender(), which also signs the payload. Consider verifying ownership of the URL, for example with a challenge request, before you store it.

Each callback name maps to one or more runtime expression keys (e.g. "{$request.body#/callbackUrl}"), each of which maps to one or more operations keyed by HTTP method. Empty maps and empty arrays are skipped, passing an empty callback never produces a malformed spec.

Discriminated unions

OpenAPI 3.1's discriminator is the canonical way to describe tagged unions. DaloyJS ships two helpers from @daloyjs/core/openapi (and the root package):

  • discriminator(propertyName, mapping?): the bare spec builder. Use it when you already have a hand-rolled JSON Schema and want to attach the field cleanly.
  • discriminatedUnion(propertyName, variants, opts?): a Standard-Schema- compatible wrapper that both validates at runtime (dispatching on the discriminator value) and exposes .toJSONSchema() so the OpenAPI generator emits { oneOf, discriminator } automatically.
ts
import { z } from "zod";
import { discriminatedUnion } from "@daloyjs/core";

// .strict() rejects unknown keys (e.g. an injected `isAdmin`) with a 422
// instead of silently stripping them.
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 }),
);

At runtime the wrapper rejects non-objects, missing or non-string discriminators, and unknown discriminator values (including inherited names such as __proto__, constructor, and toString) with a clear Standard Schema issue, then defers to the matching variant's validator for everything else.

Edit this page on GitHub

Last updated