Skip to main content

API modules and versioning

A module groups chanfana OpenAPIRoute classes under one API version, with optional middleware that runs only for those routes.

src/modules/users/index.ts
import {defineModule} from '@ltv/cwb';
import {UserCreate, UserDelete, UserGet, UserList, UserUpdate} from './user.routes';

export const users = defineModule({
version: 1,
routes: {
'GET /users': UserList,
'POST /users': UserCreate,
'GET /users/:id': UserGet,
'PATCH /users/:id': UserUpdate,
'DELETE /users/:id': UserDelete,
},
});

Route keys

  • A key is "METHOD /path": one method, whitespace, then a path that starts with /.
  • Allowed methods are GET, POST, PUT, PATCH and DELETE (ENDPOINT_METHODS lists them in lowercase). The RouteKey type uses uppercase methods; at runtime the method is case-insensitive.
  • Paths use Hono's syntax, for example /users/:id.
  • Each value is an OpenAPIRoute subclass (the class, not an instance).

Versions and final paths

defineModule normalizes version:

versionMounted as
omittedv1
2 (number)v2
'2024-01' (string)2024-01, used as-is

The final path is {path}/{version}{route}, built by buildEndpointPath, which removes duplicate and trailing slashes:

createApp pathModule versionRoute keyFinal path
'/' (default)1GET /users/v1/users
'/api'1GET /users/:id/api/v1/users/:id
'/api/''v2'GET /users/api/v2/users
import {buildEndpointPath} from '@ltv/cwb';

buildEndpointPath('/', 'v1', '/users'); // '/v1/users'
buildEndpointPath('/api/', 'v2', '/users'); // '/api/v2/users'
buildEndpointPath('/api', undefined, '/'); // '/api'

A plain ApiModule object without version (not created through defineModule) is mounted at {path}{route}, with no version segment.

Running two versions side by side

Modules with different versions can declare the same route keys, because their final paths differ:

src/index.ts
const usersV1 = defineModule({version: 1, routes: {'GET /users': UserListV1}});
const usersV2 = defineModule({version: 2, routes: {'GET /users': UserListV2}});

createApp({
path: '/api',
modules: [usersV1, usersV2], // GET /api/v1/users and GET /api/v2/users
openApi: (hono) => fromHono(hono, {docs_url: '/docs'}),
});

Route validation

createApp validates every module's routes before it registers anything, and throws:

ProblemError message
Missing method, extra tokens, or a path not starting with /Invalid route: GET users
Unsupported methodInvalid route method "OPTIONS" in "OPTIONS /users" (allowed: GET, POST, PUT, PATCH, DELETE)
Two routes, in any modules, with the same method and final pathDuplicate route "GET /api/v1/users" (declared by "GET /users" and "GET /users")

Module middleware

middleware on a module runs before that module's route handlers only. Use it for auth, role checks or tenant resolution that applies to one group of routes.

src/modules/admin/index.ts
import type {MiddlewareHandler} from 'hono';
import {defineModule} from '@ltv/cwb';
import {AdminStats} from './admin-stats.route';

const requireAdmin: MiddlewareHandler = async (c, next) => {
if (c.req.header('x-role') !== 'admin') {
return c.respondForbidden();
}
await next();
};

export const admin = defineModule({
version: 1,
middleware: [requireAdmin],
routes: {'GET /admin/stats': AdminStats},
});

Module middleware is registered in the same route registration as each handler (router.get(path, ...middleware, Endpoint)), so it runs only when Hono dispatches the request to one of that module's routes. App-wide middleware from createApp({middleware}) runs before it.

The first matching route wins

Hono dispatches to the first matching route in registration order, which is module order, then route order within a module. With overlapping patterns this decides which handler, and which module middleware, runs:

const privateItems = defineModule({
middleware: [requireAuth],
routes: {'GET /items/:id': ItemGet},
});
const publicItems = defineModule({
routes: {'GET /items/public': PublicItems},
});

// Wrong: /v1/items/public matches /items/:id first, so ItemGet and requireAuth handle it
createApp({modules: [privateItems, publicItems], openApi: (hono) => fromHono(hono)});

// Right: list the module with the more specific path first
createApp({modules: [publicItems, privateItems], openApi: (hono) => fromHono(hono)});
Order modules from specific to generic

Duplicate detection only catches identical method and path pairs. It does not detect overlapping patterns such as /items/:id and /items/public, so check module order whenever patterns overlap.

EndpointRegistry

createApp creates a new EndpointRegistry for each app, so two apps never share routes. You can use the class directly with a chanfana router, for example in a custom setup without createApp:

import {fromHono} from 'chanfana';
import {Hono} from 'hono';
import {EndpointRegistry, buildEndpointPath} from '@ltv/cwb';

const app = new Hono();
const registry = new EndpointRegistry()
.get('/ping', Ping)
.post(buildEndpointPath('/api', 'v1', '/items'), ItemCreate)
.register('get', '/private', Private, [requireAuth]); // middleware for this route only

registry.getEndpoints(); // copy of [{method, path, handler, middleware?}]
registry.applyTo(fromHono(app));
MemberDescription
register(method, path, handler, middleware?)Add an endpoint; method is lowercase (EndpointMethod)
get / post / put / patch / delete (path, handler)Shortcuts for register without middleware
applyTo(router)Register every endpoint on a chanfana router; returns the registry
getEndpoints()A copy of the registered EndpointConfig entries
Custom routers

OpenAPIRouter is typed with chanfana's single-handler signature, but applyTo calls router[method](path, ...middleware, Endpoint). chanfana's fromHono router supports that. A custom or wrapped router must accept Hono middleware before the endpoint class, or module middleware would be registered as the handler.