@zudojs/openapi
v1.6.0
Published
OpenAPI 3.0 and 3.1 specification generation, validation, and serialization for Zudojs applications.
Downloads
289
Maintainers
Readme
@zudojs/openapi
OpenAPI 3.0 and 3.1 specification generation, validation, and serialization for Zudojs applications.
Documentation: zudojs.oyinlola.site/docs/packages-openapi · For AI agents: Markdown version, llms.txt
Installation
npm install @zudojs/openapiQuick Start
OpenAPIManager coordinates route collection, schema conversion, document
assembly, validation and serving.
import { OpenAPIManager } from "@zudojs/openapi";
const manager = new OpenAPIManager({
version: "3.1.0",
info: { title: "Orders API", version: "1.2.0" },
servers: [{ url: "https://api.example.com" }],
});
manager.addRoute({
method: "get",
path: "/users/:id",
metadata: {
openapi: {
operationId: "users.get",
summary: "Get a user",
tags: ["users"],
parameters: [{ name: "id", in: "path", schema: { type: "string" } }],
responses: {
"200": { description: "User found" },
"404": { description: "No such user" },
},
},
},
});
const document = manager.generate(true); // true = validate while generating
const json = manager.toJSON();
const yaml = manager.toYAML();Every documented response reaches the document — 4xx, 5xx and default
included. :id becomes {id}, every template slot is documented as a
required path parameter (declared or not), and params / query /
headers / body / response schema fields accept @zudojs/schema schemas
— see Generating from a route table. To
generate from an @zudojs/http router instead of adding routes by hand, use
that package's generateOpenAPIDocument(router) or mountOpenAPI(router).
Generation is idempotent: call generate() as often as you like.
Serving the document
const response = manager.toResponse({ format: "json" });
// { status: 200, headers: { "content-type", "cache-control" }, body }{ status, headers, body } is framework-agnostic; hand it to whichever HTTP
adapter you use.
Serving a documentation page
toUIResponse returns the same { status, headers, body } shape carrying a
complete HTML page that reads the specification from specUrl. Pair it with
toResponse, which serves the specification itself:
app.get("/openapi.json", () => manager.toResponse());
app.get("/docs", () => manager.toUIResponse({ specUrl: "/openapi.json" }));Swagger UI is rendered by default; pass renderer: "redoc" for ReDoc.
manager.toUIResponse({ specUrl: "/openapi.json", renderer: "redoc" });renderOpenAPIUI(options) returns the HTML string on its own, without a
manager. Both accept the same options:
| Option | Meaning | Default |
| ---------------- | -------------------------------------------------- | ------------------ |
| specUrl | Where the page fetches the document from (required) | — |
| title | Page title and header text | "API reference" |
| renderer | "swagger" or "redoc" | "swagger" |
| logo | Header logo, or false for none | Zudo wordmark |
| favicon | Favicon URL or data URI, or false | Zudo favicon |
| customCss | CSS appended after the built-in theme | — |
| assetsBaseUrl | Where the viewer's own JS and CSS load from | pinned jsDelivr |
| assetIntegrity | SRI hashes { script?, stylesheet? }, or false | pinned hashes |
| contentSecurityPolicy | CSP header from toUIResponse, or false | restrictive policy |
| connectSources | Extra origins "Try it out" may call | — |
| swaggerOptions | Forwarded to SwaggerUIBundle; ignored by ReDoc | — |
By default the viewer loads from exact, pinned versions on jsDelivr
([email protected], [email protected]; exported as SWAGGER_UI_VERSION
and REDOC_VERSION) with Subresource Integrity hashes, so a changed or
compromised CDN file is refused by the browser instead of running on your
API's origin.
toUIResponse also sends x-content-type-options: nosniff and a
content-security-policy built by buildOpenAPIUIContentSecurityPolicy:
scripts only from the asset origin plus the hash of the page's one inline
bootstrap script, default-src 'none', no plugins, no <base>, framing
only by the same origin, and connect-src limited to the page's origin, the
spec URL and every absolute servers[].url in the document (the servers
"Try it out" calls). Add more with connectSources, replace the policy with
a string, or pass contentSecurityPolicy: false to send none.
assetsBaseUrl points the viewer's assets at a self-hosted copy, which is what
an air-gapped deployment needs — the default CDN renders a blank page with no
egress. Swagger UI loads swagger-ui.css and swagger-ui-bundle.js from that
base; ReDoc loads redoc.standalone.js. A self-hosted copy gets no
integrity attribute unless you pass assetIntegrity, since it may be a
different build.
manager.toUIResponse({ specUrl: "/openapi.json", assetsBaseUrl: "/vendor/swagger" });Caller-supplied text is escaped, and input that would break out of the page is
refused rather than mangled: a javascript: or vbscript: URL throws (the
scheme is read after stripping the control characters browsers ignore, so
java\nscript: is caught too), a data: URL that is not an image throws
(the assets base lands in <script src>), an empty specUrl throws, and
customCss containing </style> throws — that sequence ends the style block
and lets the rest be parsed as HTML.
Generating from a route table
createOpenAPIDocumentFromRoutes(routes, options) builds a document from a
list of structural OpenAPIRouteDescriptors, so any route source can be
documented from what it actually registers. @zudojs/http's
generateOpenAPIDocument(router) and mountOpenAPI(router) feed it the
router's routes; other sources (such as @zudojs/api operations) build the
same descriptors.
import { createOpenAPIDocumentFromRoutes } from "@zudojs/openapi";
import { objectSchema, stringSchema } from "@zudojs/schema";
const user = objectSchema({ id: stringSchema().uuid(), name: stringSchema() });
const document = createOpenAPIDocumentFromRoutes(
[
{ method: "GET", path: "/users/:id", summary: "Get a user", tags: ["users"],
responses: { "200": { schema: user }, "404": { description: "No such user" } } },
{ method: "POST", path: "/users", operationId: "users.create",
body: objectSchema({ name: stringSchema() }),
responses: { "201": { schema: user } } },
],
{
info: { title: "Users API", version: "1.0.0" },
securitySchemes: { bearer: { type: "http", scheme: "bearer" } },
security: [{ bearer: [] }],
schemas: { User: user }, // components; converted when they are @zudojs/schema schemas
validate: true,
},
);An OpenAPIRouteDescriptor is RouteOpenAPIMetadata plus method and
path, flattened:
| Field | Meaning |
| --- | --- |
| method | Any case; must be get put post delete options head patch trace |
| path | /users/:id or /users/{id}; optional/regex/wildcard segments are resolved by the source |
| summary, description, operationId, tags, deprecated, servers, externalDocs | Copied to the operation |
| security | Operation security; [] marks it public (overrides document security) |
| params, query, headers, cookies | One object schema each; every property becomes a parameter (required from the schema; path parameters always required) |
| body | A schema (sent as application/json, required) or { schema, contentType?, required?, description?, example? }; a raw requestBody wins |
| responses | Per status / NXX / default: a Response Object, or { schema, description?, contentType?, headers?, example? } (description defaults to the reason phrase, e.g. "Not Found") |
| parameters | Explicit parameters, highest precedence |
| inferredParameters | Parameters the source derived itself (e.g. a regex constraint), lowest precedence |
| hidden | true leaves the operation out |
Every path template slot is documented as a required string parameter even
when nothing declares it, and a declared path parameter the template does
not contain is dropped with a warning rather than producing an invalid
document. The same metadata fields work in manager.addRoute({ method, path,
metadata: { openapi } }). Conversion warnings reach onRouteWarning
(one message at a time), onSchemaWarning under the name "routes", and
manager.routeWarnings(), each prefixed with the route (DELETE /users/:id: ...).
No response status is invented. A route that documents no responses (or
responses: {}) gets a spec-valid default response described as
"Undocumented response" (UNDOCUMENTED_RESPONSE_DESCRIPTION) and a route
warning, so the omission shows up instead of a 204 DELETE being published
as 200:
const document = createOpenAPIDocumentFromRoutes(
[{ method: "DELETE", path: "/users/:id" }],
{ info, onRouteWarning: (message) => logger.warn(message) },
);
// paths["/users/{id}"].delete.responses → { default: { description: "Undocumented response" } }
// warned: "DELETE /users/:id: no responses are documented; ..."createOpenAPIManagerFromRoutes(routes, options) returns the manager instead
(for toResponse / toUIResponse); manager.setRoutes(routes) replaces its
whole route set, rejecting duplicates before anything changes, and
routeDescriptorToRouteInfo converts a descriptor to addRoute's shape.
Branding
ReDoc, Scalar and several other viewers read a logo from the non-standard
info["x-logo"] field. Generated documents carry the Zudo mark there by
default, so a spec opened in one of them shows a logo rather than nothing.
new OpenAPIManager({ info }).generate().info["x-logo"];
// { url: "data:image/svg+xml;…", href: "https://zudojs.oyinlola.site", altText: "Zudo", … }The branding option controls it, and the same value is used by
toUIResponse for the page header:
- omitted or
true— the Zudo mark false— nox-logo, and no logo on the page- an
OpenAPILogo({ url, href?, altText?, backgroundColor? }) — your own
new OpenAPIManager({ info, branding: false });
new OpenAPIManager({
info,
branding: { url: "https://acme.example/logo.svg", href: "https://acme.example", altText: "Acme" },
});A logo already present on info["x-logo"] is never overwritten, whatever
branding says.
The brand assets are exported as inline SVG strings and as data URIs, so a page
can show them without a network request: ZUDO_MARK_SVG, ZUDO_MARK_DARK_SVG,
ZUDO_WORDMARK_SVG, ZUDO_WORDMARK_DARK_SVG, ZUDO_FAVICON_SVG, a
*_DATA_URI counterpart for each, plus ZUDO_SITE_URL, zudoLogo(overrides?)
and svgToDataUri(svg). The types are OpenAPIUIOptions,
OpenAPIUIRenderer, OpenAPIUIResponse, OpenAPIUIAssetIntegrity and
OpenAPILogo.
Schemas
addSchema converts a @zudojs/schema schema into an OpenAPI component and
registers it.
import {
objectSchema,
stringSchema,
numberSchema,
optionalSchema,
} from "@zudojs/schema";
manager.addSchema(
"User",
objectSchema({
id: stringSchema().uuid(),
age: numberSchema().int().min(0),
nickname: optionalSchema(stringSchema()),
}),
);produces
{
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid", "maxLength": 255 },
"age": { "type": "integer", "minimum": 0 },
"nickname": { "type": "string", "maxLength": 255 }
},
"required": ["id", "age"]
}Objects, arrays, enums, literals, unions, discriminated unions,
intersections, records, tuples, sets, optionals, nullables, defaults,
refinements, transforms, lazy schemas, bigints and the coercion wrappers are
all converted, along with string and number constraints (min, max,
length, pattern, format, int, multipleOf, gt, lt). Constraints
on a coercing schema (coerce.number().int().min(1)) are carried through.
@zudojs/schema caps every string at 255 characters and every array at 1000
items unless the schema sets its own .max(). The emitted maxLength /
maxItems carry that effective limit (read from @zudojs/constants'
SCHEMA_DEFAULT_MAX_STRING_LENGTH / SCHEMA_DEFAULT_MAX_ARRAY_LENGTH, the same
constants the parser uses), so a client generated from the document is never
told it may send a payload the server rejects. To emit only the bounds a
schema declares explicitly, pass implicitLimits: false — to
createOpenAPIManager, createOpenAPIDocumentFromRoutes, convertSchema or
resolveSchemaInput; a string with its own .max(40) keeps maxLength: 40
either way.
A property is listed in required exactly when the object parser rejects
its absence: fields wrapped in optional, fields with a default, and
any / unknown fields are left out, and .required() forces every key
back on. A default supplied as a factory (.default(() => new Date())) is
invoked once and its value emitted.
Anything that cannot be expressed exactly produces a warning rather than a
silent {}:
const manager = new OpenAPIManager({
info,
onSchemaWarning: (name, warnings) => logger.warn({ name, warnings }),
});
manager.schemaWarnings(); // Map<componentName, warnings>Version awareness
3.0 and 3.1 spell several keywords differently, and the difference is not cosmetic: the 3.1 spelling in a 3.0 document is either rejected by a strict tool or ignored by a lenient one, so the constraint silently disappears from the published contract. The converter emits whichever spelling the target version defines.
| Constraint | 3.1.x | 3.0.x |
| ---------------- | ---------------------------- | ------------------------------------ |
| gt(5) | exclusiveMinimum: 5 | minimum: 5, exclusiveMinimum: true |
| lt(10) | exclusiveMaximum: 10 | maximum: 10, exclusiveMaximum: true|
| positive() | exclusiveMinimum: 0 | minimum: 0, exclusiveMinimum: true |
| nullable | type: ["string", "null"] | nullable: true |
| literal | const: "yes" | enum: ["yes"] |
| tuple | prefixItems | minItems / maxItems |
In 3.1 exclusiveMinimum carries the bound itself; in 3.0 it is a boolean
modifier on minimum. Emitting the number into a 3.0 document produced a
keyword of the wrong type, which is how a gt(5) constraint used to vanish
from a 3.0 spec. Both spellings are now correct, and no 3.1-only keyword
reaches a 3.0 document.
A regular expression's flags have nowhere to go: OpenAPI's pattern carries
the source and nothing else. A /^abc$/i pattern would therefore become
case-sensitive in the document — a published contract stricter than the code
validating against it. Rather than drop the flags silently, the converter
emits the source and raises a warning naming them.
Recursive schemas are detected and reported rather than overflowing the stack —
register the recursive type as a named component and reference it with $ref.
Use convertSchema directly when you want the conversion without the
registry:
import { convertSchema } from "@zudojs/openapi";
const { schema, warnings } = convertSchema(mySchema, { version: "3.0.3" });The converter reads
@zudojs/schema's runtime fields structurally rather than importing its classes, which keeps it usable with any compatible object. The field names it depends on are listed at the top ofschemaConverter.core.tsand covered by tests.
Validation
const result = manager.validate();
result.valid; // boolean
result.errors; // OpenAPIValidationIssue[]
result.warnings; // OpenAPIValidationIssue[]The validator checks:
- required document fields, and that
openapiis a supported version - that every operation declares at least one response, keyed by a status
code, a
4XX-style range, ordefault, each with a description - that path templates and
in: "path"parameters agree in both directions — the classic "{id}is in the path but nowhere inparameters" mistake - that path parameters are marked required, and that no parameter is declared twice in one list (an operation-level parameter may override a path-level one with the same name and location)
- that no two paths are identical apart from their template parameter names
(
/users/{id}next to/users/{userId}) operationIduniqueness and length- that every
securityrequirement names a scheme declared incomponents.securitySchemes— a typo there yields a document that looks protected - that every local
$refresolves within the document - that every non-local
$refuses a scheme a resolver may reasonably be pointed at — onlyhttpandhttps. A$refis an instruction to whatever dereferences the document, sofile:///etc/passwdorhttp://169.254.169.254/latest/meta-data/turns the spec into a file-read or SSRF sink in the resolver downstream. Any other scheme is an error; an http(s) or relative reference is legal OpenAPI and so is a warning, telling you something outside the document will be fetched - that no path still uses
:idinstead of{id}
assertValid throws an OpenAPIValidationError that carries the issues:
try {
manager.generate(true);
} catch (error) {
if (error instanceof OpenAPIValidationError) {
console.error(error.format()); // one line per issue
error.issues; // structured
}
}Building a document by hand
import { OpenAPIDocumentBuilder } from "@zudojs/openapi";
const document = new OpenAPIDocumentBuilder({
info: { title: "Orders API", version: "1.2.0" },
})
.addServer({ url: "https://api.example.com" })
.addTag({ name: "orders" })
.addSecurityScheme("bearerAuth", { type: "http", scheme: "bearer" })
.addSecurity({ bearerAuth: [] })
.addSchema("Order", { type: "object" })
.addPath("/orders", {
get: { responses: { "200": { description: "OK" } } },
post: { responses: { "201": { description: "Created" } } },
})
.build();The builder and the manager assemble documents through the same registry, so both paths produce the same shape and obey the same rules.
Serialization
toOpenAPIJSON and toOpenAPIYAML both take a document. The YAML output is
real YAML — a document is plain maps, arrays and scalars, and strings that YAML
would reinterpret (true, null, 1.0, anything opening with a reserved
character) are quoted.
References
import { createComponentReference } from "@zudojs/openapi";
createComponentReference("schemas", "User");
// { $ref: "#/components/schemas/User" }Component names are escaped per RFC 6901, so a name containing / or ~
still produces a pointer that resolves.
Errors
All errors extend OpenAPIError, which is the @zudojs/errors class
re-exported (a BaseError), and
default to status 500, not exposed — these are failures while a service builds
or validates its own specification, not responses to a client request:
OpenAPIValidationError · OpenAPIDocumentError · OpenAPIComponentError ·
OpenAPIComponentConflictError · OpenAPIReferenceError ·
OpenAPIRouteError · OpenAPISchemaError · OpenAPISerializationError ·
OpenAPIVersionError · OpenAPIOperationError
Each accepts statusCode and expose overrides.
Use Cases
- Generating API documentation from route metadata
- Serving a spec, and a branded Swagger UI or ReDoc page, from your own app
- Feeding client and server code generators
- Contract checks in CI
