DocTreen
Features

Runtime validation

Reject invalid requests with a structured 422 — same schema as the docs, in every language.

The same schema you declared for documentation can validate every incoming request. Enable it once at the adapter level and DocTreen checks request.body, request.query, and request.params before your handler executes. Invalid requests are rejected with a structured 422 response.

Validation needs a real parser, so each language pairs with its natural one: Zod on Node, Pydantic v2 on Python, and the S:: schema metadata on PHP (with query & path-param coercion). Builder-declared (s.*) routes on Node and Python are documented but not validated.

const express = require('express');
const { z } = require('zod');
const { expressAdapter, defineRoute } = require('doctreen/express');

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

app.post('/users', defineRoute(
  (req, res) => res.status(201).json({ id: 1, ...req.body }),
  {
    request: { body: z.object({ name: z.string().min(2), email: z.string().email() }) },
  }
));

// Turn validation on for every Zod-declared route on this app
app.use(expressAdapter(app, { validate: true }));
from flask import Flask
from pydantic import BaseModel, EmailStr

from doctreen.adapters.flask import define_route, flask_adapter

app = Flask(__name__)


class CreateUser(BaseModel):
    name: str
    email: EmailStr


@app.post("/users")
@define_route(request={"body": CreateUser})
def create_user():
    ...


# Turn validation on for every Pydantic-declared route on this app
app.register_blueprint(flask_adapter(app, {"validate": True}))
// config/doctreen.php
'validate' => true,
use Doctreen\Schema\S;

Route::post('/users', [UserController::class, 'store'])->doc([
    'request' => ['body' => S::object(['name' => S::string(), 'email' => S::string()])],
]);

The validation middleware attaches to the api and web middleware groups automatically; set 'middleware_groups' => [] and register \Doctreen\Laravel\ValidateRequests yourself for full control.

Sending { "email": "nope" } to POST /users now returns the same envelope in every language:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "error": "validation_failed",
  "issues": [
    { "path": "body.name",  "message": "Required",      "code": "invalid_type"   },
    { "path": "body.email", "message": "Invalid email", "code": "invalid_string" }
  ]
}

Every part is checked in one pass, so a request with a bad body and a bad query reports both rather than making you fix one and retry.

Properties

  • Opt-in. Default is off; you set validate: true once per adapter. Upgrading cannot suddenly start rejecting requests.
  • Per-route override. Pass validate: false to defineRoute / @DocRoute / @define_route / ->doc() to skip validation on a specific route while keeping the docs entry — or validate: true to enable it on one route when the adapter default is off.
  • Async refinements work (Node) — internally uses safeParseAsync, so .refine(async ...) and pipelines are honoured.
  • All adapters that own validation. Express, Fastify, Hono, Koa, NestJS, Flask, and Laravel. Hono and Koa require the adapter to be called before routes (their middleware does not retro-apply); the others work regardless of order. On FastAPI and Django/DRF, DocTreen deliberately does not install request validation — FastAPI already rejects invalid bodies and a DRF serializer already validates in the view, each with an error format your clients depend on. Those adapters focus on drift instead.

If you previously hand-rolled a NestJS pipe, an Express middleware running zodSchema.parse(req.body), or a Flask before-request hook doing manual Pydantic parsing on every endpoint, this replaces it.

Path parameters v1.15

Validate path parameters with the same schema that documents them. Add a params schema to request and :id-style segments are checked before the handler runs — a mismatch returns the same structured 422 (issue paths are prefixed params.).

app.get('/orders/:id', defineRoute(
  (req, res) => res.json({ id: req.params.id }),
  {
    request: {
      params: z.object({ id: z.string().uuid() }),
      query:  z.object({ limit: z.coerce.number().default(50) }),
    },
  }
));

GET /orders/not-a-uuid now returns 422 with { "path": "params.id", "message": "Invalid uuid", … }. This retires the hand-rolled requireUuid helpers you'd otherwise repeat on every route. Path parameters are also typed in the OpenAPI export from the declared schema.

Write-back (coerce + defaults) v1.15

By default validation checks the request but leaves it untouched, so coercions and defaults never reach your handler. Opt in with validate: { writeback: true } and the parsed payload is written back:

app.use(expressAdapter(app, { validate: { writeback: true } }));

// query: z.object({ limit: z.coerce.number().default(50) })
//   GET /orders/abc          → req.query.limit === 50    (number, default applied)
//   GET /orders/abc?limit=7  → req.query.limit === 7     (number, coerced)
  • Express / Fastify / NestJS — written to req.body / req.query / req.params (DocTreen clears the Express 5 req.query getter for you).
  • Hono — overlaid on c.req.param() / c.req.query() / c.req.json(), and also exposed as c.get('doctreenValidated').
  • Koa — written to ctx.request.body and ctx.query; coerced path params are exposed on ctx.state.doctreenValidated.params.
  • Flask — path parameters are replaced in the view's arguments directly; body and query land on g.doctreen_validated (Flask's request.args is immutable and its JSON is cached). ?limit=5 reaches you as the integer 5.
  • Laravel'validate' => ['writeback' => true] in config/doctreen.php; coerced query and path params are written back onto the request.

Write-back is opt-in, so upgrading never silently changes what your handler sees.

Response assertion v1.15

Catch handlers that return a body which doesn't match the declared response schema. This is a development aid — it never coerces the response, and it is status-aware in every language: a 409 body is checked against the schema declared for 409, not against the success schema, so error envelopes stop producing phantom failures.

app.use(expressAdapter(app, { validate: { response: 'warn' } }));
// 'warn'  → logs the mismatch and sends the response unchanged
// 'throw' → surfaces it in development
// 'off'   → default; no response checking

The same {"validate": {"response": "warn"}} / 'validate' => ['response' => 'warn'] shape works on Flask and Laravel.

Standard error envelope v1.15

The 422 body is a stable contract — { error: 'validation_failed', issues: [{ path, message, code }] } — identical across Node, Python, and PHP. Routes that declared validators document it in the OpenAPI export as a named DoctreenValidationError component, so codegen emits the type and clients stop guessing the shape of a validation error.

On this page