Model Binding & Validation
this.model or an automatic 400.MasterController brings ASP.NET Core’s [ApiController] behaviours to Node: a static schemas block per controller binds and validates input into this.model, invalid requests answer 400 ValidationProblemDetails automatically, errors are RFC 7807 application/problem+json, route parameters get typed constraints, and actions reply with typed results such as this.created() and this.noContent().
Declarative binding#
Each key of static schemas is an action name (or '*' as a fallback). A schema may target body, query, route, form, files, and headers — the equivalents of [FromBody], [FromQuery], [FromRoute], [FromForm]. The results are merged, coerced, and stripped of unknown fields into this.model; this.modelState holds isValid and errors.
// app/controllers/itemsController.js
export default class ItemsController {
static schemas = {
create: { body: { name: { type: 'string', required: true, minLength: 2 }, price: { type: 'number', min: 0 }, tags: { type: 'array', items: 'string' } } },
index: { query: { page: { type: 'integer', default: 1, min: 1 }, q: 'string' } },
update: { route: { id: 'integer!' }, body: { name: 'string', price: { type: 'number', min: 0 } } },
'*': { query: { tenant: 'string' } }, // fallback for other actions
};
async create() {
// this.model = { name, price, tags } — validated, coerced, unknown fields stripped
const item = await this.db.Item.add(this.model);
await this.db.saveChanges();
this.created(`/items/${item.id}`, item);
}
async index() {
const { page, q } = this.model; // page is a Number (coerced from the query string)
this.ok(await this.db.Item.skip((page - 1) * 20).take(20).toList());
}
}A bare schema (no body/query/… keys) binds the body for POST/PUT/PATCH and the query for other methods. Binding runs after authorization and before your beforeAction filters.
The automatic 400#
When validation fails the action never runs. Errors are keyed like ASP.NET’s ModelState ("address.city", "tags[1]"):
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"instance": "/items",
"errors": {
"name": ["name must be at least 2 characters"],
"price": ["price must be at least 0"]
}
}Rules#
A rule is an object { type, required, default, min, max, minLength, maxLength, pattern, enum, items, properties, custom, nullable, trim, coerce, message } — the DataAnnotations set. Shorthands: a type string ('string'), a required type ('integer!'), or a function (treated as custom).
type:string(default) ·number·integer·boolean·email·url·uuid·date·datetime·array·object·any.- Values from query/route/form strings are coerced by default (
coerce: falseto disable); strings are trimmed (trim: false). min/maxapply to numbers and dates;minLength/maxLengthto strings and arrays;itemsis the element rule of an array;propertiesa nested object schema;defaulta value or function.custom(value, model, field)returns an error string, orfalse/nullwhen valid — may be async.- A JSON-Schema-like
{ type: 'object', properties, required: [], additionalProperties }form is accepted too.
static schemas = {
create: {
body: {
email: { type: 'email', required: true },
age: { type: 'integer', min: 18, max: 120 },
role: { type: 'string', enum: ['user', 'admin'], default: 'user' },
website: { type: 'url', nullable: true },
address: { type: 'object', properties: { city: 'string!', zip: { type: 'string', pattern: '^\\d{5}$' } } },
tags: { type: 'array', items: 'string', maxLength: 10 },
handle: { type: 'string', custom: (value, model, field) => value.startsWith('@') ? null : 'handle must start with @' },
},
},
};Manual control#
export default class ItemsController {
static schemas = { create: { body: { n: 'integer!' } } };
static autoValidate = false; // bind, but do not auto-400
async create() {
if (!this.modelState.isValid) return this.unprocessable(this.modelState.errors); // 422 instead of 400
// ...
}
async other() {
const m = await this.bind({ body: { n: 'integer!' } }); // explicit; null when invalid (400 already written)
if (!m) return;
const { valid, value, errors } = await this.validateModel(m, { n: { type: 'integer', min: 1 } }); // no response written
if (!valid) return this.validationProblem(errors);
this.ok(value);
}
}
// The same engine outside controllers
master.validation.validate(data, schema);
master.validation.bind(requestObject, spec);Typed route constraints#
Append a constraint to a route parameter and the router only matches when it passes — a miss lets the next route try. Available: int, number, bool, uuid, alpha, alphanum, slug, length(n), minlength(n), maxlength(n), min(n), max(n), regex:…. Unknown constraint names fail at registration.
// app/routes.js — {id:int} in ASP.NET
router.route('/items/:id(int)', 'items#show', 'get'); // obj.params.id is a Number
router.route('/items/:slug(slug)', 'items#bySlug', 'get');
router.route('/codes/:code(regex:^[A-Z]{3}$)', 'items#code', 'get');
router.route('/users/:id(uuid)', 'users#show', 'get');
router.route('/pages/:n(min:1)', 'pages#show', 'get');Typed results#
The ControllerBase helpers, available on controllers and minimal-API handlers:
| Helper | Response |
|---|---|
this.ok(data) | 200 JSON |
this.created(location, data) | 201 + Location |
this.accepted() | 202 |
this.noContent() | 204 |
this.badRequest() · this.unauthorized() · this.forbidden() · this.notFound(detail) · this.conflict() · this.unprocessable(errors) | 400 / 401 / 403 / 404 / 409 / 422 as ProblemDetails |
this.problem({ status, title, detail, type, instance, …extensions }) | Any RFC 7807 problem |
this.validationProblem(errors) | 400 ValidationProblemDetails |
this.file(pathOrBuffer, { contentType, fileName, inline }) | Streams a file with Content-Disposition |
this.statusCode(code, body) | Anything else |
export default class ItemsController {
async show(obj) {
const item = await this.db.Item.find(obj.params.id);
if (!item) return this.notFound(`Item ${obj.params.id} does not exist`);
this.ok(item); // 200
}
async create() {
const item = await this.db.Item.add(this.model);
await this.db.saveChanges();
this.created(`/items/${item.id}`, item); // 201 + Location
}
async destroy(obj) {
await this.db.Item.where('i => i.id == $$', obj.params.id).executeDelete();
this.noContent(); // 204
}
async download() {
this.file('./exports/items.csv', { contentType: 'text/csv', fileName: 'items.csv' }); // streams + Content-Disposition
}
async reserve() {
this.accepted(); // 202
}
async fail() {
this.problem({ status: 409, title: 'Already reserved', detail: 'Try another slot', slot: 3 }); // RFC 7807
}
}application/problem+json (type/title/status/instance plus the existing error/errorId) whenever the client looks like an API client — it accepts JSON, is an XHR, sent a JSON body, or a bearer token. Browsers keep getting the HTML error pages.