Heximon Logo
Capabilities

OpenAPI

Generate an OpenAPI 3.1 document from your Contract route tables at runtime with OpenApiGenerator, register security schemes manually with the SecurityScheme factory, and serve Scalar or Swagger UI inline.

You already described your API once — the Contract carries every route's method, path, schemas, and scopes. Point a generator at it and you get a standards-compliant OpenAPI 3.1 document that reads the same schemas validating incoming requests, so your docs can't drift from what the server actually accepts.

Generation happens at runtime: OpenApiGenerator is a plain class you construct and call from a controller — there's no plugin to register.

Install the package

pnpm add @heximon/openapi

There's nothing to add to vite.config.ts. The Scalar and Swagger UI renderers load their JavaScript from a CDN at view time, so you don't install a UI SDK either.

Generate the document

new OpenApiGenerator(options) takes info (title + version), the contracts to document, and optional servers, security, setOperationId, and jsonQuery. Call generate() to build the document — it builds once and returns the same memoized object on every subsequent call.

src/docs/openapi.ts
import { OpenApiGenerator, Scalar, SecurityScheme } from "@heximon/openapi";
import { ProductsApi } from "../products/product.api";

export const documentPath = "/openapi.json";

export const openApiGenerator = new OpenApiGenerator({
  info: {
    title: "Products API",
    version: "1.0.0",
    description: "A small Heximon API documented with OpenAPI 3.1.",
  },
  servers: [{ url: "http://localhost:3000", description: "Local dev server" }],
  contracts: [ProductsApi],
  security: {
    schemes: [SecurityScheme.bearer("bearerAuth", { bearerFormat: "JWT" })],
    default: { scheme: "bearerAuth" },
    // GET routes are public; everything else requires the bearer token.
    requirementFor: (route) => (route.method === "GET" ? [] : undefined),
  },
});

export const docsUserInterface = new Scalar({
  title: "Products API — Reference",
  documentPath,
});

generate() emits one operation per route, deriving parameters, request body, and responses from that route's schemas. A duplicate method + path across contracts throws the first time you serve it.

Map contract fields to the spec

The generator reads everything straight off the contract routes — there's no extra annotation layer:

Contract fieldBecomes
Route path + methodAn OpenAPI path item + operation
body / query / pathParams / headers schemasThe request body + parameters
.files(...) file fieldsA multipart/form-data request body with a binary file leaf per field
responses[status] schemasThe response schemas, keyed by status
.download() / .html() / .responseContentType(type)A non-JSON 2xx response body (binary / text), see below
summary / descriptionOperation metadata
.deprecated()deprecated: true on the operation
scopes(...)OAuth2 scope lists — the only inferred security field

Schemas embed as JSON Schema. A schema that carries a JSON-Schema conversion (SchemaObject fields via the @heximon/schema/valibot converter, Zod v4 standalone values, the framework's own response envelopes) embeds at full fidelity; one that can't be converted degrades to a permissive {} rather than failing the whole document. A SchemaObject still renders its object shape — property keys and required list — even when the leaves are empty.

Full valibot leaf types

Raw valibot schemas (v.string(), v.object(), …) do not carry the Standard-Schema JSON Schema sister-spec, so they degrade to {} by default. Pass a custom converter that chains valibotJsonSchema from @heximon/schema/valibot before the standard path to get full-fidelity output:

src/docs/docs.module.ts
import { valibotJsonSchema } from "@heximon/schema/valibot";
import { OpenApiGenerator, StandardSchemaConverter } from "@heximon/openapi";

new OpenApiGenerator({
  contracts: [ProductsApi],
  converter: (schema, direction) =>
    valibotJsonSchema(schema, direction) ?? StandardSchemaConverter.convert(schema, direction),
});

valibotJsonSchema guards on ~standard.vendor === "valibot" and calls @valibot/to-json-schema internally. It returns undefined on any conversion failure, so the fallback to {} is preserved for schemas that cannot be represented in JSON Schema. Pipe actions that add constraints (e.g. v.email(), v.minLength()) are included where supported; purely custom transforms are silently omitted.

A route that declares file fields with .files(...) emits a multipart/form-data request body instead of application/json: each file field becomes a { type: "string", format: "binary" } leaf (an array of them for a multiple field), merged with any scalar body fields, and a field is required unless it declares required: false.

Reuse a schema with $ref

A schema that carries a title is promoted into components.schemas (under that title) and every request/response occurrence becomes a { $ref: "#/components/schemas/<title>" } — so a DTO shared across routes appears once, not duplicated inline.

Within one generate() a schema reused across routes is also deduplicated by object identity. An untitled schema inlines exactly as before. To force an untitled schema into components (or override the promoted name), pass a schemaName(schema, fallback) resolver:

src/docs/openapi.ts
new OpenApiGenerator({
  info: { title: "Products API", version: "1.0.0" },
  contracts: [ProductsApi],
  // Promote a normally-inline schema under an explicit name; return undefined to keep the default.
  schemaName: (_schema, fallback) => fallback ?? "AnonymousResult",
});

There's no auto-counter: two structurally-distinct schemas resolving the same name throw a collision error, so give each a unique title (or schemaName).

Non-JSON and binary responses

A route whose 2xx body is binary or text — a file download, an HTML page — declares its wire type with .responseContentType(type), or the .download() / .html() sugar. The generator emits that media type for the 2xx response ({ type: "string", format: "binary" } for binary, { type: "string" } for text/*), while declared error statuses stay application/json:

src/uploads/upload.api.ts
import { Contract, Route } from "@heximon/contract";
import { keyParams, notFound } from "./upload.schema";

export class UploadsApi extends Contract({
  prefix: "/api/uploads",
  routes: {
    // 200 is application/octet-stream (a Blob); the declared 404 stays JSON.
    download: Route.get("/:key").pathParams(keyParams).download().responses({ 404: notFound }),
  },
}) {}

The matching client typing decodes the 2xx body to a Blob (binary) or string (text/*) and skips response-schema validation for it — a 2xx schema declared on a non-JSON route is informational (OpenAPI only), never run.

Deprecate a route

Mark a route as deprecated with .deprecated() on the builder. The OpenAPI generator reads the flag and emits deprecated: true on the operation — tooling that renders the spec (Scalar, Swagger UI) will visually strike through or dim it. The route remains fully operational; the flag is a signal to callers that the route will be removed in a future API version.

src/products/product.api.ts
import { Contract, Route } from "@heximon/contract";
import { productList } from "./product.schema";

export class ProductsApi extends Contract({
  prefix: "/api/products",
  routes: {
    // Replacement route — callers should migrate to this one.
    list: Route.get("/v2")
      .summary("List products")
      .responses({ 200: productList }),

    // Deprecated: kept for backwards compatibility, will be removed.
    listLegacy: Route.get("/")
      .deprecated()
      .summary("List products (deprecated — use /v2 instead)")
      .responses({ 200: productList }),
  },
}) {}

The recommended approach for introducing breaking changes is prefix-versioning: serve the next version of an API surface under a new path prefix (/v2/, /api/v2/), keep the old prefix alive and deprecated while clients migrate, then remove the old routes once adoption is complete.

A Contract maps cleanly to a versioned prefix — author a v2 contract with the updated routes and register both contracts with the same OpenApiGenerator to document both in one spec.

Register security manually

Security schemes are never inferred from your middleware — you register each scheme explicitly:

Factory methodScheme
SecurityScheme.bearer(name, { bearerFormat?, description? })HTTP bearer token
SecurityScheme.basic(name, { description? })HTTP basic auth
SecurityScheme.apiKey(name, { in, name, description? })API key in a header, query, or cookie
SecurityScheme.oauth2(name, { flows, description? })OAuth2 (carries scopes)
SecurityScheme.openIdConnect(name, { openIdConnectUrl, description? })OpenID Connect (carries scopes)

A route's scopes(...) is the single field the generator infers. Only OAuth2 and OpenID Connect schemes can carry scopes, so they flow into those operations' required-scope lists; every other scheme type gets an empty scope list.

src/docs/openapi.ts
import { OpenApiGenerator, SecurityScheme } from "@heximon/openapi";
import { UsersApi } from "../users/users.api";

const document = new OpenApiGenerator({
  info: { title: "Users API", version: "1.0.0" },
  contracts: [UsersApi],
  security: {
    schemes: [
      SecurityScheme.oauth2("oauth2", {
        flows: {
          authorizationCode: {
            authorizationUrl: "https://auth.example.com/authorize",
            tokenUrl: "https://auth.example.com/token",
            scopes: { "users.read": "Read users", "users.write": "Write users" },
          },
        },
      }),
    ],
    default: { scheme: "oauth2" },
  },
}).generate();

// A route that declared .scopes("users.read") now requires it:
// document.paths["/api/users/{id}"].get.security === [{ oauth2: ["users.read"] }]

security.default applies one scheme to every operation. requirementFor(route, ctx) overrides per operation — return an explicit requirement list, [] to mark the operation public, or undefined to fall back to the default.

A default scheme must be registered first.security.default.scheme names one of the schemes you passed in schemes — if the name doesn't match, the constructor throws [heximon-openapi] security.default references unregistered scheme "X" — add it to security.schemes. Reference it by the same name you gave the helper.

Serve the document and a UI

The document is data and a renderer returns a self-contained HTML string, so a plain inline Controller serves both — the generator and renderer are module-level values you call, not DI providers.

src/docs/docs.controller.ts
import type { Controller, Get } from "@heximon/http";
import { docsUserInterface, openApiGenerator } from "./openapi";

export class DocsController implements Controller {
  // GET /openapi.json — the OpenAPI 3.1 document (built once, then memoized)
  public async document(action: Get<"/openapi.json">) {
    return openApiGenerator.generate();
  }

  // GET /docs — a Scalar reference page (text/html) that fetches /openapi.json
  public async ui(action: Get<"/docs">) {
    return action.html(docsUserInterface.render());
  }
}

The owning module declares only the controller:

src/docs/docs.module.ts
import { Module } from "@heximon/runtime";
import { DocsController } from "./docs.controller";

export class DocsModule extends Module({
  http: { controllers: [DocsController] },
}) {}

Choose a renderer

Both Scalar and SwaggerUserInterface take { title, documentPath } and return render() — an HTML page that fetches the document from documentPath. Swap one line to switch UIs:

src/docs/renderers.ts
import { Scalar, SwaggerUserInterface } from "@heximon/openapi";

const scalar = new Scalar({ title: "My API", documentPath: "/openapi.json" });
const swagger = new SwaggerUserInterface({ title: "My API", documentPath: "/openapi.json" });

Each renderer loads its script from a CDN (jsDelivr); override scriptPath / assetPath to self-host it instead. For a fully custom page, extend the abstract OpenApiUserInterface and implement render(), then return its HTML with action.html(...), which sets the text/html content type for you.

Document the right routes

The generator reads contracts, not controllers, so only routes declared on a Contract appear in the spec — an inline controller's routes (Get<"/:id">) leave nothing to iterate at runtime. To document a route, declare it on a shared Contract and bind the controller with implements Controller<SomeApi>.

Inline routes are silently absent — there's no warning. The generator is a runtime value that reads contract route tables; an inline route's metadata exists only at compile time, so the generator can't see it and emits no diagnostic. The route is simply missing from the spec.The same is true of the REST client and @heximon/mcp — all three read the contract, not inline metadata. Author the routes on a Contract to make every contract-driven tool see them.

See also

  • Contracts — the Contract + Route builder that the document is generated from; the one source of truth shared by the server, the client, and the spec.
  • Controllers — bind a controller to a contract so the document describes the routes you actually serve.
  • Validation & DTOs — author each schema once; it both validates requests and embeds into the spec as JSON Schema.
  • REST Client — the typed client and the OpenAPI spec are two views of the same contract value.
  • OpenAPI + MCP — a contract-mode products API, a manually registered bearer scheme with GET routes marked public, and a Scalar reference page served from a plain controller.
Copyright © 2026