MasterController

Model Binding & Validation

Declare what an action accepts; get a validated 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.

itemsController.js
// 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]"):

400 application/problem+json
{
  "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: false to disable); strings are trimmed (trim: false).
  • min/max apply to numbers and dates; minLength/maxLength to strings and arrays; items is the element rule of an array; properties a nested object schema; default a value or function.
  • custom(value, model, field) returns an error string, or false/null when valid — may be async.
  • A JSON-Schema-like { type: 'object', properties, required: [], additionalProperties } form is accepted too.
javascript
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#

javascript
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
// 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:

HelperResponse
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
javascript
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
  }
}
Thrown errors become ProblemDetails
An unhandled exception in a controller or route answers 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.