Skip to main content
Version: 4.x

OpenAPI Spec Generation

ExpressoTS v4 generates an OpenAPI 3.1 document straight from your application: no decorators to sprinkle, no second source of truth to maintain. The same scanner that powers Studio reads your routes, controllers, and @Body() DTOs, then enriches the document with real recorded traffic so your responses reflect what the app actually returns.

Everything here is dev/CI-time only. Nothing is mounted in your production server and there is zero runtime cost.

Provenance, not gospel

Generated documents are marked info.x-expressots-generated: "inferred" (or "mixed" / "extracted" when precise schemas are available). Treat the output as a high-quality starting point and review it before publishing it as an authoritative contract.

Two ways to generate​

1. From the CLI (headless, CI-friendly)​

npx expressots openapi emit --out openapi.json --src ./src

This runs a static scan and writes openapi.json to the project root. Useful flags:

FlagDefaultDescription
--out, -oopenapi.jsonOutput file path.
--src./srcSource directory to scan.
--titleExpressoTS APISets info.title.
--api-version(all)Restrict output to one version, e.g. --api-version 2 emits only /v2/*.
--global-prefix(auto)Global route prefix (e.g. /api). Auto-detected from setGlobalRoutePrefix(...); pass "" to force none.
--fail-on-drift <path>(off)Diff against a committed spec and exit non-zero on drift (CI gate).
Global prefix

If your app calls setGlobalRoutePrefix("/api"), the generated paths are prefixed (/api/users, …) so they match what the app actually serves and what Studio shows. The CLI recovers the prefix statically from your source, so a CLI-generated spec and a Studio-generated one line up; this keeps --fail-on-drift from reporting false positives. Override with --global-prefix if detection misses it.

The first time you run it, the CLI installs @expressots/studio as a dev dependency (the generator lives there).

2. From Studio (enriched with live traffic)​

Open Studio, go to the API Client view, and expand the OpenAPI spec panel. From there you can:

  • Regenerate the document from the current routes plus everything recorded so far.
  • Download openapi.json or Copy spec to the clipboard.
  • Check spec drift against a committed file.

Because Studio sees real requests and responses, the document it produces includes response schemas, status codes, and examples that a static scan alone can't know.

What goes into the document​

SourceContributes
Route scanner (static)Paths, HTTP methods, controller tags, path parameters, @Body() DTO shapes.
Recorded traffic (Studio)Response schemas + examples per status code, request examples, observed query parameters.
Validation adapter (extractSchema)Precise request-body JSON Schema (Zod today).

Precise schemas with Zod​

When a route's @Body() uses a Zod schema, ExpressoTS can extract an exact JSON Schema instead of inferring one from a sample. The Zod adapter uses Zod 4's native z.toJSONSchema() (falling back to the optional zod-to-json-schema package for Zod 3). You can call the same helper directly:

import { schemaToJsonSchema } from "@expressots/core";
import { z } from "zod";

const CreateUser = z.object({
name: z.string(),
age: z.number().int().optional(),
});

const jsonSchema = schemaToJsonSchema(CreateUser);

When a precise schema is available it takes precedence over the inferred sample, and the document's provenance becomes mixed or extracted.

Versioning​

By default a single document covers every version (paths keep their /v1, /v2 prefixes). Pass --api-version <n> to emit a document scoped to one version, which is handy when you publish a separate spec per major version.

How version filtering matches

--api-version keeps routes whose path begins with a /v<n> segment. When Studio generates the spec from the running app, versioned routes already carry their /v1, /v2 prefix (the adapter applies @Version at runtime), so filtering is exact. A static CLI scan only sees the literal controller/route paths, so --api-version filters reliably only when the version appears in the path. If you rely solely on the @Version decorator, generate from Studio (live) for accurate per-version output.

The document's info.version is read from your application's package.json, so it tracks your API's releases rather than the framework version. Override it with --title for the name; the version follows your project automatically.

Spec drift detection​

This is the part decorator-only generators can't do: comparing your published contract against what the app actually does. Studio (and --fail-on-drift) report:

  • Routes present in code but missing from the committed spec.
  • Routes documented in the spec but missing from code.
  • Status codes seen in real traffic that the spec doesn't document.
  • Required fields the spec promises that are absent in a percentage of recorded responses (e.g. "required field email absent in 14% of 200s").

In CI:

npx expressots openapi emit --fail-on-drift openapi.json

The command exits non-zero when structural drift is found, so a stale contract fails the build. Field-frequency findings require recorded traffic and surface in Studio.

Keeping clients in sync​

A typical workflow once you have openapi.json:

# 1. Generate the spec
npx expressots openapi emit --out openapi.json

# 2. Lint it (catches missing descriptions, invalid schemas, etc.)
npx @stoplight/spectral-cli lint openapi.json

# 3. Generate a typed client
# Option A: Orval (TypeScript + your HTTP client of choice)
npx orval --input openapi.json --output ./src/api/client.ts
# Option B: openapi-generator (many languages)
npx @openapitools/openapi-generator-cli generate \
-i openapi.json -g typescript-axios -o ./generated-client

# 4. Gate breaking changes in CI
npx expressots openapi emit --fail-on-drift openapi.json

Commit openapi.json to your repo, regenerate it on every change, and let the drift gate keep code and contract honest.

Getting useful response schemas​

Response schemas and examples come from real recorded traffic, so a route only gets them once it has been exercised with a response that carries a body. Two things to keep in mind:

  • Hit every route. A route you never call shows a placeholder default response until traffic arrives.
  • Watch for 304 Not Modified. Browsers and fetch send conditional headers (If-None-Match), so a repeat GET can return 304 with no body, and that is what gets documented instead of the full 200. To capture the real payload, trigger a fresh request (e.g. disable cache, or send Cache-Control: no-cache) so the server responds 200 with the body.

Limitations​

The generator focuses on the common case and intentionally defers some things:

  • Schema inference is shallow for non-Zod bodies (primitives, arrays, objects). Use Zod for precise request schemas.
  • Authentication / security schemes, polymorphic (oneOf) schemas, and non-JSON content types are not modeled yet.
  • We don't auto-mount a public, consumer-facing docs page in your app. For interactive exploration during development you already have Studio's API Client (live route discovery, DTO-prefilled bodies, try-it-out, replay). What's not built in is a hosted rendering of the published spec for your API's consumers; serve the generated openapi.json to any renderer (Swagger UI, Redoc, Scalar) - it's a file you own and host however you like.

See also​

  • API Versioning: how @Version shapes the generated paths.
  • DTO Validator: validating request bodies with Zod, Yup, or class-validator.
  • Validation: the pluggable validation layer behind schemaToJsonSchema.