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:
| Field | Instance | Collection |
|---|---|---|
req, res, user | yes | yes |
doc | loaded document | — |
body | parsed body (see below) | parsed body |
query | parsed query | parsed 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:
| Scope | GET | POST |
|---|---|---|
| Instance | read | update |
| Collection | list | create |
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/:fieldarray 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.