Skip to content

Repository files navigation

covenix

Zod-powered decorators for Express APIs — typed routes, runtime validation, and accurate OpenAPI from a single source of truth.

Documentation · API Reference

covenix lets you describe each endpoint with explicit Zod schemas and ergonomic decorators. From that one description it wires Express routes, validates every request, and generates a swagger.json that always matches what the code actually does — no separate build step, no config file, no code generation.

import { z } from 'zod';
import createError from 'http-errors';
import { Route, Tags, Get, Post, Params, Body, Returns, Param, BodyParam } from 'covenix';

const UserSchema = z.object({ id: z.string().uuid(), username: z.string() }).meta({ id: 'User' });
const CreateUserSchema = z.object({ username: z.string() }).meta({ id: 'CreateUser' });

@Route('users')
@Tags('Users')
export class UsersController {
  @Get('{id}')
  @Params(z.object({ id: z.string().uuid() }))
  @Returns(200, UserSchema)
  @Returns(404, ErrorSchema)
  async getUser(@Param('id') id: string) {
    const user = await db.users.findById(id);
    if (!user) throw new createError.NotFound();
    return user;
  }

  @Post()
  @Body(CreateUserSchema)
  @Returns(201, UserSchema)
  async createUser(@BodyParam() body: z.infer<typeof CreateUserSchema>) {
    return db.users.create(body);
  }
}

Why covenix

The Zod schema is the contract. The same schema that validates a request also produces its OpenAPI definition, so the two can never drift.

  • One source of truth. Request validation and the generated spec come from the same schemas.
  • Runtime validation included. Zod parses, coerces, and defaults every request before your handler runs. Handlers receive clean, typed data.
  • No build magic. Just tsc. Decorators run at class-definition time and store metadata via reflect-metadata; swagger is generated at startup.
  • Zero extra deps for schema conversion. Zod 4 ships z.toJSONSchema() natively, so schema → JSON Schema needs no helper library.
  • A typed client, generated. The same metadata produces a standalone, dependency-free TypeScript client — replacing a tsoa → swagger → openapi-generator pipeline with one accurate hop.

Installation

npm install covenix zod reflect-metadata express

covenix requires Zod 4+ and TypeScript 5+ with experimental decorators.

TypeScript configuration

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "module": "nodenext",
    "types": ["reflect-metadata"],
  },
}

Notes:

  • "type": "module" is required in package.json (ESM) to coexist with verbatimModuleSyntax + module: nodenext.
  • emitDecoratorMetadata is not required — covenix uses explicit parameter decorators and explicit instance registration, so it never needs runtime type metadata.
  • Import reflect-metadata once at your app's entry point.

Getting started

// app.ts
import 'reflect-metadata';
import express from 'express';
import { Covenix } from 'covenix';
import { UsersController } from './users.controller.js';

const app = express();
app.use(express.json());

const api = new Covenix({ info: { title: 'My API', version: '1.0.0' } });

// You own construction — inject dependencies explicitly.
api.register(new UsersController(db));

api.mount(app);

app.listen(3000);

That's it. A single Covenix instance owns your controllers; mount wires the Express routes and validation middleware, and the same instance generates swagger (see below). Request data is parsed by Zod before each handler runs.

Grouping & versioning

Give a set of controllers a shared base path — typically an API version segment — with a registration prefix or a group. It composes with each controller's own @Route prefix and shows up in both the routes and the spec:

api.register(new UsersController(svc), { prefix: '/v1' }); // → /v1/users

api.group('/v1', (v1) => {
  v1.register(new UsersController(svc)); // → /v1/users
  v1.register(new AuthController(auth)); // → /v1/auth/...
});

Groups nest, and the same controller can be mounted under more than one version. See Grouping & Versioning.


Decorator reference

Class decorators

Decorator Purpose
@Route(prefix) Path prefix for every route in the controller
@Tags(...tags) OpenAPI tags applied to the controller's operations

Method decorators

Decorator Purpose
@Get(path?) @Post(path?) @Put(path?) @Patch(path?) @Delete(path?) HTTP method + path (use {id} for path params)
@Params(schema) Validate req.params against a Zod object
@Query(schema) Validate req.query
@Body(schema) Validate req.body
@Returns(status, schema?) Declare a response — stackable; omit schema for no body (e.g. 204)
@ReturnsFile(status, options?) Declare a binary/file response (return a FileResponse)
@Sse(schema?, options?) Stream a Server-Sent Events (text/event-stream) response
@Security(scheme, scopes?) Require a named auth scheme — class or method, stackable = OR
@Use(...middleware) Run Express middleware before the handler — class or method
@Summary(text) Operation summary in swagger
@Description(text) Operation description (longer prose) in swagger
@OperationId(id) Operation id (defaults to the handler method name)
@Deprecated() Marks the operation deprecated in swagger
@Example(value, status?) Example for the request body, or a response (status) — stackable

Parameter decorators

Decorator Injects
@Param(name) req.params[name]
@QueryParam(name) req.query[name]
@BodyParam(name?) the whole validated body, or one field of it
@File(name) an uploaded file as a web File (multipart)
@Files(name) uploaded files as File[] (multipart)
@Header(name) req.headers[name]
@Principal() the principal from the @Security handler
@Req() / @Res() raw Express Request / Response (escape hatch)

To return a file or binary stream instead of JSON, pair @ReturnsFile with a FileResponse return value — covenix streams the body and sets Content-Type/Content-Disposition (plus optional disposition/headers). For HTTP Range / partial content, return a RangeFileResponse instead. See File downloads.

To receive files, put a z.file() (or z.array(z.file())) field in a @Body schema: covenix auto-detects the route as multipart/form-data, parses it with multer, and injects each file via @File / @Files. See File uploads.

To protect a route, register named schemes on the instance (new Covenix({ security: { bearerAuth: bearer(handler) } })) and mark routes with @Security('bearerAuth', scopes?); the handler's result is injected via @Principal(). See Authentication.

For arbitrary Express middleware (rate limiting, caching, logging, custom auth), use @Use(...middleware) on a method or controller. covenix runs the chain @Security → @Use → multipart → handler; class-level @Use runs before method-level, and middleware that sends a response short-circuits the handler.


Validation

Each route gets validation middleware generated from its schemas:

Source Validated against Failure status
req.params @Params schema 400
req.query @Query schema 400
req.body @Body schema 422
handler return @Returns schema 500

On success, the original request values are replaced with the parsed (coerced, defaulted) output so handlers always receive clean data. Responses are validated the same way: the handler's return value is checked against the matching @Returns schema, and a mismatch always throws a ValidationError through next(err) — exactly like a request failure, in every environment. covenix never decides what to do about it; your error middleware does (log it and respond 500, swallow it, or surface the drift however you like).

Errors flow through Express

covenix never sends an error response itself. A failed validation calls next(err) with a ValidationError that carries the Zod issues and a status (400 for params/query, 422 for body, 500 for a response that doesn't match its @Returns schema), so it travels the same Express error pipeline as anything your handlers throw. ValidationError and SecurityError both extend CovenixError (carrying .status), so you stay in control of the response shape via your own error middleware:

import { CovenixError } from 'covenix';

app.use((err, req, res, next) => {
  if (err instanceof CovenixError) {
    return res.status(err.status).json({ status: err.status, message: err.message });
  }
  next(err); // your http-errors / fallback handling
});

If you don't want to write that, covenix ships an optional covenixErrorHandler that renders errors as RFC 9457 Problem Details (application/problem+json) — the standard error shape, overridable via formatError:

import { covenixErrorHandler } from 'covenix';

app.use(covenixErrorHandler());
// 422 → { type: 'about:blank', title: 'Unprocessable Entity', status: 422,
//         errors: [{ path: ['name'], message: '...' }] }

OpenAPI / Swagger

Name your top-level schemas so they become reusable components:

const UserSchema = z
  .object({
    /* ... */
  })
  .meta({ id: 'User' });
//   → referenced as #/components/schemas/User in swagger

Anonymous inline schemas are allowed but produce inlined swagger (no $ref).

The same Covenix instance you registered controllers on generates the spec — no need to list controllers a second time:

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

Or mount a browsable docs UI in one line with api.serveDocs(app) (Scalar by default; { ui: 'swagger-ui' | 'redoc' }). The UI is self-hosted from an optional peer dependency, or { cdn: true } for no install. See OpenAPI / Swagger.

api.swagger() builds the OpenAPI document from the registered controllers' metadata. It doesn't depend on routes being mounted, so for CI or frontend client generation a generate-swagger script can register controllers and call api.swagger() headlessly, writing swagger.json to disk without starting the server.

Static generation — no instances required

Swagger is derived entirely from class-level metadata, so if you only need the spec you don't have to construct controllers (or their dependencies) at all. generateSwagger(controllers) takes the controller classes directly and returns the OpenAPI document:

// generate-swagger-static.ts
import { writeFile } from 'node:fs/promises';
import { generateSwagger } from 'covenix';
import { HealthController } from './controllers/HealthController.js';
import { UsersController } from './controllers/UsersController.js';
import { AuthController } from './controllers/AuthController.js';

const swagger = generateSwagger([HealthController, UsersController, AuthController]);
await writeFile('swagger.json', JSON.stringify(swagger, null, 2));

This is the lightest path for CI spec checks and client codegen: no Covenix instance, no service wiring, no Express — just the classes and their decorators. Use api.swagger() instead when you already have a configured instance on hand (e.g. serving the spec from the running app).

Spec version: 3.1 by default

covenix emits OpenAPI 3.1.0 — its native form, since Zod 4's z.toJSONSchema() is JSON Schema draft 2020-12 (what 3.1 uses). For tooling with only partial 3.1 support (e.g. openapi-generator's typescript-fetch), pass specVersion: '3.0' to down-convert nullables, exclusive bounds, const, and binary annotations:

api.swagger({ specVersion: '3.0' });
generateSwagger([UsersController], info, { specVersion: '3.0' });

See OpenAPI / Swagger for the full conversion table.


Typed client

covenix can generate a standalone, fully-typed TypeScript client — the modern replacement for a tsoa → swagger → openapi-generator-cli pipeline. The generated file has no runtime dependency, so a front end imports it and calls your API with full types:

import { generateContract, generateTypeScriptClient } from 'covenix';

// controllers → contract (a codegen-oriented IR) → standalone client
const contract = generateContract([UsersController, AuthController]); // or api.contract()
await writeFile('api.gen.ts', generateTypeScriptClient(contract));
import { createClient, CovenixClientError } from './api.gen'; // no covenix import

const api = createClient({ baseUrl: 'https://api.example.com' });

const user = await api.users.get({ params: { id } }); // → User (throws on non-2xx)
const res = await api.users.get.raw({ params: { id } }); // → { status, body } union
for await (const event of await api.health.events()) {
  /* @Sse → AsyncIterable */
}

Operations are grouped by tag; methods return the success body and throw a typed CovenixClientError on non-2xx (use .raw() for the status-discriminated union). File responses come back as Blob (with HTTP Range), SSE as an AsyncIterable. See Typed Client.


Migrating

Coming from another tool? See the migration guides:


License

MIT © Joe Ferner

About

Zod-powered decorators for Express APIs — typed routes, runtime validation, and accurate OpenAPI from a single source of truth.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages