Skip to content

Migrating from a hand-written OpenAPI document

If you currently author openapi.json / openapi.yaml by hand (or with an editor like Stoplight/Swagger Editor) and then implement handlers to match, covenix inverts the workflow:

  • You stop writing the document. You write Zod schemas + decorators; covenix generates the OpenAPI 3.1 document from them.
  • Validation comes free. The same schema that produces a requestBody also validates the request at runtime — the spec and the implementation can't drift, because they're the same source.
  • The generated document is still a plain object. api.swagger() returns a mutable OpenAPIV3_1.Document, so anything covenix doesn't emit you can add by post-processing it (see the escape hatch).

The mental shift: a hand-written spec is the source of truth that your code must chase; in covenix the code is the source of truth and the spec is a build artifact.

Document structure

OpenAPI document fieldcovenix
openapi: '3.1.0'Emitted automatically (3.1 default; swagger({ specVersion: '3.0' })).
info.title / info.versionnew Covenix({ info: { title, version } })
info.description / contact / license / …new Covenix({ info }) takes the full OpenAPI Info Object
serversnew Covenix({ servers: [{ url }] })
paths@Route prefix + @Get/@Post/… (with {id} path params)
components.schemasNamed (.meta({ id })) Zod schemas — referenced by routes, or passed via the schemas option for route-less types
components.securitySchemesThe security map on new Covenix({ security }) (or the builders)
security (global)@Security on the controller class (applies to all its routes)
tags (names)@Tags(...) on the controller class
tags (descriptions)new Covenix({ tags: [{ name, description }] })
externalDocsnew Covenix({ externalDocs: { url } })
webhooks / x-*Post-process the generated document

Operations

Operation fieldcovenix
HTTP method + path@Get('{id}'), @Post(), @Put, @Patch, @Delete
summary@Summary('…')
description@Description('…')
operationId@OperationId('…') (defaults to the handler method name)
tags@Tags(...) (controller-level)
deprecated@Deprecated()
parameters@Params(schema) (path) + @Query(schema) (query)
requestBody@Body(schema)
responses@Returns(status, schema?) / @ReturnsFile(status, …) — stackable
security@Security(scheme, scopes?) — stackable = OR
callbacks / linksPost-process

Parameters

covenix decomposes a @Params/@Query object schema into one OpenAPI parameter per property — path params are always required, query params follow the schema's optionality.

Hand-written parametercovenix
in: path@Params(z.object({ id: z.string() })) + @Param('id')
in: query@Query(z.object({ page: z.coerce.number() })) + @QueryParam('page')
in: header@Headers(z.object({ 'x-id': z.string() })) + @HeaderParam('x-id') (reserved headers are validated, not emitted)
in: cookie@Cookies(z.object({ sid: z.string() })) + @CookieParam('sid') (needs a cookie parser ahead of the route)
parameter description/example.describe(...) / .meta({ examples }) on the property schema
requiredPath → always; query/header/cookie → non-.optional() property
parameter deprecated.meta({ deprecated: true }) on the property → deprecated on the parameter
style / explodePost-process (covenix emits the default schema form)

Request bodies

Hand-written requestBodycovenix
application/json@Body(z.object({ … }))
multipart/form-data (file upload)@Body with a z.file() / z.array(z.file()) field — auto-detected (File uploads)
required: trueAlways set when @Body is present
application/x-www-form-urlencoded, text/*, otherPost-process (covenix emits JSON or multipart)
request example@Example(value) (no status)

Responses

Hand-written responsecovenix
responses.<code> (JSON)@Returns(code, schema) — stackable, one per status
no-body response (e.g. 204)@Returns(204) (omit the schema)
binary body (format: binary)@ReturnsFile(code, { contentType, description }) + return a FileResponse / RangeFileResponse (File downloads)
response headers@Returns(code, schema, { headers: { 'X-…': z.…() } })
response description@Returns(code, schema, { description }) (or @ReturnsFile(code, { description }))
response example@Example(value, code)
default responsePost-process

Schema Object → Zod

This is the heart of it: the OpenAPI Schema Object (a JSON Schema dialect) is exactly what Zod 4's z.toJSONSchema() produces, so every keyword has a Zod spelling. Name a schema with .meta({ id }) to get a #/components/schemas/*$ref; leave it anonymous to inline.

OpenAPI / JSON SchemaZod
type: string / number / booleanz.string() / z.number() / z.boolean()
type: integerz.number().int()
type: array, itemsz.array(T)
type: object, properties, requiredz.object({ a: T, b: T.optional() })
nullable (3.0) / type: [..., 'null'] (3.1)T.nullable()
enumz.enum(['a', 'b'])
constz.literal('x')
format: date-time / datez.iso.datetime() / z.iso.date()
format: email / uuid / uriz.email() / z.uuid() / z.url()
format: binaryz.file() (upload) or @ReturnsFile (download)
minLength / maxLength.min(n) / .max(n) on a string
pattern.regex(/…/)
minimum / maximum.min(n) / .max(n) on a number
exclusiveMinimum / exclusiveMaximum.gt(n) / .lt(n)
multipleOf.multipleOf(n)
minItems / maxItems.min(n) / .max(n) on an array
default.default(v)
additionalProperties: falsez.object({ … }) (covenix's default — objects are strict)
additionalProperties: <schema>z.record(z.string(), T) or z.object({…}).catchall(T)
allOfz.intersection(A, B) / A.extend({ … })
anyOfz.union([A, B])
oneOfz.discriminatedUnion('kind', [A, B]) (emits oneOf)
tuple (prefixItems)z.tuple([A, B])
title / description.meta({ title }) / .describe('…')
deprecated / readOnly / writeOnly.meta({ deprecated: true }) / .meta({ readOnly: true })
example / examples.meta({ examples: [v] }) (or @Example on the operation)
$ref reuse.meta({ id: 'Name' }) (referenced wherever the schema is used)

Reuse

A .meta({ id }) schema becomes a single components/schemas entry and is referenced by $ref everywhere it appears — the same deduplication you'd hand-roll with $ref, but automatic. Nested named schemas are hoisted for you.

A couple of keywords have no direct Zod spelling: uniqueItems and minProperties/maxProperties need a .refine(...) (and won't appear in the generated schema), and discriminator is not emitted (a discriminated union generates plain oneOf). Add those by post-processing if a consumer requires them.

Security schemes

OpenAPI security schemecovenix
type: http, scheme: bearerbearer(handler, { bearerFormat })
type: http, scheme: basicbasic(handler)
type: apiKey (header/query/cookie)apiKey({ in, name }, handler)
type: oauth2oauth2(flows, handler)
type: openIdConnect / mutualTLSRaw { scheme: { type: 'openIdConnect', … }, handler }
per-operation security@Security('name', scopes?)
multiple accepted schemes (OR)Stack @Security decorators

Each scheme bundles its OpenAPI definition with a runtime handler, so declaring it also enforces it. See Authentication.

The escape hatch: post-processing

api.swagger() (and generateSwagger(...)) return a plain OpenAPIV3_1.Document. Anything in the tables above marked "post-process" — and any vendor extension or future OpenAPI feature — you add by mutating that object before serving or writing it:

typescript
function buildSpec() {
  const doc = api.swagger();

  // Things covenix doesn't model — webhooks, vendor extensions, response links:
  (doc as Record<string, unknown>)['x-internal-build'] = process.env.BUILD_SHA;
  doc.webhooks = {
    userCreated: { post: { requestBody: { content: { 'application/json': {} } } } },
  };
  return doc;
}

app.get('/swagger.json', (_req, res) => res.json(buildSpec()));

Because the document is regenerated on each call, this stays a pure transform — no files to keep in sync, and the validated parts still come straight from your schemas.

What this buys you

Going from a hand-maintained document to generated-from-code means the spec cannot lie: the request body shape in the document is the exact schema that rejects a bad request, the response shape is the exact schema covenix validates your handler's return value against, and a renamed field changes both at once. The parts covenix doesn't model yet are a small, additive post-processing step rather than a second source of truth to maintain.

If you hit an OpenAPI feature you expected covenix to emit, please open an issue.