defineRoute
Explicit route definition with full schema control — defineRoute, @define_route, or ->doc().
For full control, attach metadata to a route explicitly. The declaration
carries the same keys in every language — defineRoute wraps the handler on
Node, @define_route decorates the view on Flask, ->doc() chains onto the
route on Laravel:
const { defineRoute, s } = require('doctreen/express');
// const { defineRoute, s } = require('doctreen/fastify');
// import { defineRoute, s } from 'doctreen/hono';
// const { defineRoute, s } = require('doctreen/koa');
app.post('/users', defineRoute(
(req, res) => {
res.status(201).json({ id: 1, name: req.body.name });
},
{
description: 'Create a new user account',
headers: {
Authorization: 'Bearer <token>',
'Content-Type': 'application/json',
},
request: {
body: s.object({ name: s.string(), email: s.string(), role: s.optional(s.string()) }),
query: null,
},
response: s.object({ id: s.number(), name: s.string(), email: s.string() }),
errors: {
409: 'Email address already in use',
422: { description: 'Validation failed', schema: s.object({ message: s.string(), field: s.string() }) },
},
}
));from doctreen import s
from doctreen.adapters.flask import define_route
@app.post("/users")
@define_route(
description="Create a new user account",
headers={"Authorization": "Bearer <token>"},
request={
"body": s.object({
"name": s.string(),
"email": s.string(),
"role": s.optional(s.string()),
}),
},
response=s.object({"id": s.number(), "name": s.string(), "email": s.string()}),
errors={
409: "Email address already in use",
422: {"description": "Validation failed"},
},
)
def create_user():
...@define_route attaches metadata and returns the function unchanged — it is
not a wrapper. Pydantic models are accepted anywhere a schema is expected.
use Doctreen\Schema\S;
Route::post('/users', [UserController::class, 'store'])->doc([
'description' => 'Create a new user account',
'headers' => ['Authorization' => 'Bearer <token>'],
'request' => [
'body' => S::object([
'name' => S::string(),
'email' => S::string(),
'role' => S::optional(S::string()),
]),
],
'response' => S::object(['id' => S::number(), 'name' => S::string(), 'email' => S::string()]),
'errors' => [
409 => 'Email address already in use',
422 => ['description' => 'Validation failed'],
],
]);A bare schema is shorthand for the body: 'request' => S::object([...]).
Schema resolution order
| Adapter | Priority |
|---|---|
| Express | defineRoute → JSDoc → runtime inference |
| Fastify | defineRoute → Fastify native JSON Schema → JSDoc |
| Hono | defineRoute → JSDoc |
| Koa | defineRoute → JSDoc |
| NestJS | @DocRoute / @Doc* decorators |
| Flask | @define_route → Flask URL converter types |
| Laravel | ->doc() macro |
Options
The same option set in every language (camelCase keys in Python dicts, array keys in PHP):
| Option | Type | Notes |
|---|---|---|
description | string | Human-readable description shown in the UI |
summary | string | Short title for the operation |
headers | Record<string, string> | Request headers — example value as the map value |
request.body | schema | Request body (Zod / Pydantic / builder) |
request.query | schema | Query parameters |
request.params | schema | Path parameters (v1.15+) |
response | schema | Record<number, schema> | Success response — a single schema (200) or a status-keyed map |
errors | Record<number, string | { description?, schema? }> | Documented error responses |
validate | boolean | Per-route override of adapter-level validate setting |
tags | string[] | Override the path-segment default tag |
security | Array<Record<string, string[]>> | Per-route security override |
hidden | boolean | Hide from docs UI and OpenAPI |
examples | RouteExamples | Multi-example bodies and responses (v1.11+) |
callbacks | Record<string, CallbackDef> | Per-operation OpenAPI 3.1 callbacks |
Path parameters v1.15
request.params declares a schema for path parameters, alongside body and
query. It's validated when validate is
on (a structured 422 on mismatch) and typed as the path parameters in the
OpenAPI export:
defineRoute(handler, {
request: {
params: z.object({ id: z.string().uuid() }),
query: z.object({ page: z.coerce.number().default(1) }),
body: z.object({ name: z.string() }),
},
});On Flask you often don't need this at all — /users/<int:user_id> documents
the parameter as a number from the URL converter, with nothing to declare.
Status-keyed responses v1.15
response accepts either a single schema (documented as 200) or a
status-keyed map, letting each status carry its own schema in the OpenAPI
export. Works in all three languages — {201: Created, 200: Existing} in
Python, [201 => $created] in PHP. The single-schema form is unchanged:
defineRoute(handler, {
response: {
201: z.object({ id: z.number() }),
202: z.object({ queued: z.boolean() }),
},
});With TypeScript generics
import { defineRoute, RouteSchemas } from 'doctreen/express';
import { s } from 'doctreen';
app.post('/users', defineRoute<{ name: string }, never, { id: number; name: string }>(
(req, res) => res.status(201).json({ id: 1, name: req.body.name }),
{
description: 'Create a user',
request: { body: s.object({ name: s.string() }) },
response: s.object({ id: s.number(), name: s.string() }),
}
));See TypeScript for the full type catalogue.