OpenAPI
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
npm install @heximon/openapi
yarn add @heximon/openapi
bun 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.
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 field | Becomes |
|---|---|
| Route path + method | An OpenAPI path item + operation |
body / query / pathParams / headers schemas | The request body + parameters |
.files(...) file fields | A multipart/form-data request body with a binary file leaf per field |
responses[status] schemas | The response schemas, keyed by status |
.download() / .html() / .responseContentType(type) | A non-JSON 2xx response body (binary / text), see below |
summary / description | Operation 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:
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:
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:
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.
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 method | Scheme |
|---|---|
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.
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.
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.
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:
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:
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>.
@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+Routebuilder 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
GETroutes marked public, and a Scalar reference page served from a plain controller.
Observability & Tracing
Compile-time, module-attributed OpenTelemetry tracing with OtelPlugin — heximon.module baked onto every HTTP / CommandBus / QueryBus / EventBus span, OTLP export, samplerRatio, batchOptions.
MCP Server
Derive a Model Context Protocol server from a Contract's route table at runtime with McpServerGenerator — per-route .mcp() opt-in, marked or all selection, in-process tool execution, and stateless Streamable HTTP.