Skip to main content
Version: 57.2.0

modelRouter actions

modelRouter can register actions — named operations at collection or instance scope — alongside standard CRUD. Actions reuse the same permission model, error envelope, and OpenAPI pipeline as CRUD routes, without hand-wiring endpoints, asyncHandler, and doc loading.

When to use actions

Use actions when an endpoint performs an operation that is not a plain create/read/update/delete on the model document:

  • Collection scope (POST /todos/bulkComplete) — operate on many documents or the collection as a whole.
  • Instance scope (POST /todos/:id/markComplete) — operate on one loaded document.

Use custom routes with createOpenApiBuilder when the URL does not fit the /:id/{actionName} or /{actionName} pattern.

Example

import {modelRouter, Permissions, z} from "@terreno/api";
import {Todo} from "../models";

export const todoRouter = modelRouter("/todos", Todo, {
permissions: {
create: [Permissions.IsAuthenticated],
list: [Permissions.IsAuthenticated],
read: [Permissions.IsOwner],
update: [Permissions.IsOwner],
delete: [Permissions.IsOwner],
},
instanceActions: {
markComplete: {
method: "POST",
permissions: [Permissions.IsOwner],
handler: async ({doc}) => {
doc.completed = true;
await doc.save();
return doc;
},
summary: "Mark a todo complete",
},
},
collectionActions: {
bulkComplete: {
method: "POST",
permissions: [Permissions.IsAuthenticated],
body: z.object({ids: z.array(z.string()).min(1)}).strict(),
response: z.object({matched: z.number(), modified: z.number()}).strict(),
handler: async ({body, user}) => {
// updateMany using body.ids and user._id
},
summary: "Mark multiple todos complete",
},
},
});

Use .strict() on Zod object schemas when you want unknown JSON keys rejected at runtime. Zod’s default object behavior strips unknown keys.

Handler context

Handlers receive a single context object:

FieldInstanceCollection
req, res, useryesyes
docloaded document
bodyparsed body (see below)parsed body
queryparsed queryparsed query

Return a plain value from the handler; the framework responds with {data: <value>} and status 200 (or action.status if set). If the handler writes the response itself (res.json, streaming), return without relying on auto-wrap — the framework skips wrapping when res.headersSent is true.

Permissions

Each action requires a non-empty permissions array (same PermissionMethod types as CRUD). An empty array disables the action and returns 405, matching CRUD.

HTTP method and scope map to a CRUD permission check:

ScopeGETPOST
Instancereadupdate
Collectionlistcreate

405 — denied before a document is loaded (or on any collection action denial).
403 — denied after the instance document is loaded (e.g. IsOwner with a document that fails the check).
401 — unauthenticated when a permission requires auth (from existing auth middleware).

authenticateMiddleware honors the router’s allowAnonymous option, same as CRUD.

Validation

Optional body and query Zod schemas validate input. On failure the API returns 400 with title: "Validation failed" and field errors in meta.fields.

Parsed values are passed only through ctx.body / ctx.query. Express 5 does not allow reassigning req.query; do not mutate req.body in middleware.

When no body or query schema is configured, the raw req.body / req.query are passed through (for manual validation in the handler).

Document loading (instance actions)

Instance actions load the document by id before the handler runs. Missing documents return 404, including soft-delete metadata when applicable.

queryFilter is not applied to this load (same as CRUD permission middleware). List filters may hide a document that still exists; post-load permission checks are responsible for access control.

req.obj is set to the loaded document for parity with CRUD update/delete handlers.

OpenAPI and SDK codegen

When openApi is configured on the router (automatic with TerrenoApp and setupServer), each action is documented on first route registration. Operations use:

  • Tag — model collection name by default; override with action.tag.
  • operationId{tag}_{actionName} (e.g. todos_markComplete) for stable RTK Query hook names after SDK regeneration.

Request/response schemas are derived from Zod when body, query, or response are set. Successful responses are documented as {data: ...} to match the wire format.

Collision detection

At router build time, Terreno rejects:

  • Empty action names or names that do not match /^[A-Za-z][A-Za-z0-9_-]+$/
  • Instance action names that match a Mongoose array field path (which would collide with POST /:id/:field array routes)

Actions are registered before user endpoints callbacks, so they take precedence if paths overlap.

Migrating from endpoints

Before (hand-rolled):

endpoints: (router) => {
router.post("/generate", [authenticateMiddleware()], asyncHandler(async (req, res) => {
// manual admin check, validation, OpenAPI omitted
return res.json({data: result});
}));
},

After:

collectionActions: {
generate: {
method: "POST",
permissions: [Permissions.IsAuthenticated],
handler: async ({body, user}) => { /* ... */ return result; },
},
},

Remove duplicate asyncHandler, authenticateMiddleware, and manual {data: ...} wrapping unless you need a custom response shape.

Dependencies

@terreno/api lists zod as a peer dependency (^4.3.x). Applications that define action schemas must install zod in the backend project. Import z from @terreno/api (re-exported after OpenAPI extension) so action Zod schemas work with automatic OpenAPI generation.

@asteasolutions/zod-to-openapi is bundled with @terreno/api for OpenAPI schema generation.

Concurrency

Action handlers run the same as other Express handlers — no transactions or locking. Use application-level guards (version fields, idempotency keys) if you need to prevent double-submit races.