API modules and versioning
A module groups chanfana OpenAPIRoute classes under one API version, with optional middleware that runs only for those routes.
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,PATCHandDELETE(ENDPOINT_METHODSlists them in lowercase). TheRouteKeytype uses uppercase methods; at runtime the method is case-insensitive. - Paths use Hono's syntax, for example
/users/:id. - Each value is an
OpenAPIRoutesubclass (the class, not an instance).
Versions and final paths
defineModule normalizes version:
version | Mounted as |
|---|---|
| omitted | v1 |
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 path | Module version | Route key | Final path |
|---|---|---|---|
'/' (default) | 1 | GET /users | /v1/users |
'/api' | 1 | GET /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:
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:
| Problem | Error message |
|---|---|
Missing method, extra tokens, or a path not starting with / | Invalid route: GET users |
| Unsupported method | Invalid route method "OPTIONS" in "OPTIONS /users" (allowed: GET, POST, PUT, PATCH, DELETE) |
| Two routes, in any modules, with the same method and final path | Duplicate 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.
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)});
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));
| Member | Description |
|---|---|
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 |
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.