The order API example supports listing, creating and editing orders. Requests are validated before the business service checks customer availability and whether an order can be edited. Failures use a standard error body so clients can identify a field or explain a business restriction. Find the example source and run instructions at the end.
The example uses @nocobase/app-server 3.0.0-beta.4 and Zod 4.6.5. Command-line scripts send real HTTP requests; no administration page is needed. The pnpm request command below is a helper included in the example that signs in with a demo account. Complete installation and setup before running it.
Add request validation to a route
Import apiValidator() from @nocobase/app-server/router and place the schema before the handler. Read the matching c.req.valid('query' | 'param' | 'json') value in the handler; it already includes conversions, defaults and validation.
This excerpt belongs inside the defineApiRoutes() factory in server/routes/orders.ts. The router already installs authentication and authorization-context middleware. requireOrderAction() is the example's action-permission check, service comes from the application container, and schemas are imported from schemas.ts in the same directory.
import {
apiValidator,
apiErrorResponse,
apiErrorResponses,
dataResponse,
describeRoute,
} from '@nocobase/app-server/router';
router.post(
'/orders',
requireOrderAction('create'),
describeRoute({
tags: ['Orders'],
summary: 'Create an order',
operationId: 'ordersCreateOrder',
responses: {
'201': dataResponse(Order),
'400': apiErrorResponse(400, 'The customer is unavailable (CUSTOMER_UNAVAILABLE).'),
...apiErrorResponses,
},
}),
apiValidator('json', CreateOrderInput),
async (c) => c.json({
data: await service.create(c.get('orderPolicies'), c.req.valid('json')),
}, 201),
);Authentication and action authorization precede input validation, and all checks run before writing. apiValidator() does not check login, permissions or database existence. These mechanisms have separate responsibilities. See Authentication, permissions and data scopes for permission configuration.
Register the route declaration in server/routes/index.ts for the application runtime to load it:
import orders from './orders.js';
export default [orders];Preserve existing route declarations when adding this to an application. Declare /orders without /api or a deployment prefix; see Routes and HTTP APIs for registration details.
pnpm request POST orders '{"customerId":"11111111-1111-4111-8111-111111111111","reference":" SO-2000 ","items":[{"name":"Keyboard","quantity":2}]}'Creation returns HTTP 201 with { data }. data.reference is trimmed to SO-2000, and data.id is generated by the server. Keep that ID for subsequent edits. Repeating the request creates another order.
Source: routes and permission middleware, registration.
Define input rules
Query conversion and defaults
Query parameters arrive as strings. Convert them with z.coerce.number(), then constrain their integer range. Defaults apply when a parameter is absent.
import { z } from 'zod';
export const ListOrdersQuery = z.object({
page: z.coerce.number().int().min(1).max(1_000_000).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});The list route uses apiValidator('query', ListOrdersQuery). Its service receives c.req.valid('query'), with numeric page and pageSize values.
pnpm request GET 'orders?page=1&pageSize=1'
pnpm request GET 'orders?page=0'The first returns one order with meta.page: 1, meta.pageSize: 1 and meta.total. The second returns 400 INVALID_INPUT for the page field. Omitting pagination uses page 1 with 20 records per page.
Path parameter formats
export const OrderParams = z.object({ id: z.uuid() });The detail and update routes use apiValidator('param', OrderParams) and read c.req.valid('param').id.
pnpm request GET orders/not-a-uuid
pnpm request GET orders/99999999-9999-4999-8999-999999999999The first fails format validation with 400 INVALID_INPUT. The second is a valid UUID but does not identify an order in the example, so it returns 404 ORDER_NOT_FOUND. UUIDs are the contract for this example's orders and customers, not a requirement for other identifiers such as application users.
Required, nullable and extra body fields
Use z.strictObject() to specify writable JSON fields. Submitting an entire form object must not let a client change server-controlled fields such as id or status. Query and path schemas use z.object(); this example does not require them to reject extra parameters.
const OrderItemInput = z.strictObject({
name: z.string().trim().min(1).max(120),
quantity: z.number().int().min(1).max(1000),
});
export const CreateOrderInput = z.strictObject({
customerId: z.uuid(),
reference: z.string().trim().min(1).max(120),
items: z.array(OrderItemInput).min(1).max(20),
note: z.string().trim().max(500).nullable().optional(),
});
export const UpdateOrderInput = CreateOrderInput.partial().refine(
(value) => Object.keys(value).length > 0,
{ message: 'Provide at least one field to update.' },
);Creation requires customerId, reference and items. An update accepts only the fields being changed, but rejects an empty object. JSON quantity must be a number, not the string "2". Item names and order references are trimmed, so whitespace alone is invalid.
For updates, omitting note preserves it, note: null clears it, and an empty string is stored as an empty string. optional() permits omission; nullable() permits explicit null.
pnpm request PATCH orders/22222222-2222-4222-8222-222222222222 '{"reference":"SO-1001-A"}'
pnpm request PATCH orders/22222222-2222-4222-8222-222222222222 '{"note":null}'
pnpm request PATCH orders/22222222-2222-4222-8222-222222222222 '{"status":"confirmed"}'The first two succeed, preserving and then clearing the note. The third includes a prohibited field and returns 400 INVALID_INPUT without changing the order's status. The helper sends Content-Type: application/json; set it correctly when using another HTTP tool.
Source for these rules: input and response schemas.
Recognize validation failures
Standard errors and field paths
Submit an order item with quantity 0:
pnpm request PATCH orders/22222222-2222-4222-8222-222222222222 '{"items":[{"name":"Keyboard","quantity":0}]}'The relevant response fields are shown below, omitting developer-facing descriptions and the request's ID:
{
"error": {
"code": 400,
"status": "INVALID_ARGUMENT",
"reason": "INVALID_INPUT",
"domain": "app",
"fieldViolations": [
{ "field": "items.0.quantity", "reason": "too_small" }
]
}
}code is the numeric HTTP status, status is the standard error category, reason identifies the failure, and domain identifies who defines that reason. Each field violation also contains a description. Its optional field-level reason comes from the validator, such as Zod's too_small.
Field paths use dots, including array indexes: items.0.quantity. Extra fields and empty update objects can produce object-level issues with an empty field string. Show those as form-level feedback; not every issue belongs to an individual input.
JSON parsing failures
Malformed JSON fails before schema validation. For example, pnpm demo sends the raw body { to the create endpoint. At the pinned versions, it receives 400, status: INVALID_ARGUMENT and reason: HTTP_400.
Do not treat every 400 as INVALID_INPUT or assume every failure contains fieldViolations. The normal request helper uses JSON.stringify(); the demo script constructs the malformed request separately.
Source: HTTP walkthrough, validation and parsing tests.
Handle business errors and unexpected exceptions
Missing resources and invalid references
A valid UUID does not establish existence. A missing order named by the request URL returns 404 NOT_FOUND. An unavailable customer referenced by the body returns 400 INVALID_ARGUMENT with a customerId violation.
This branch of toOrderApiError() translates the customer error:
if (error instanceof CustomerUnavailableError) {
return new ApiError({
status: 'INVALID_ARGUMENT',
reason: 'CUSTOMER_UNAVAILABLE',
domain: 'orders',
message: error.message,
fieldViolations: [{
field: 'customerId',
reason: 'CUSTOMER_UNAVAILABLE',
description: 'Choose an available customer.',
}],
cause: error,
});
}pnpm request PATCH orders/22222222-2222-4222-8222-222222222222 '{"customerId":"99999999-9999-4999-8999-999999999999","note":"Must not be saved"}'The request returns 400 CUSTOMER_UNAVAILABLE, preserving both the original customer and note. The service queries customers using this operation's Repository policy before writing the order. Action permission denial returns 403 before format validation and existence checks; do not convert every permission denial into “not found.”
Operations prohibited by business state
A confirmed order cannot be edited. This is an unmet business precondition, expressed as FAILED_PRECONDITION, which maps to HTTP 400:
if (error instanceof OrderLockedError) {
return new ApiError({
status: 'FAILED_PRECONDITION',
reason: 'ORDER_LOCKED',
domain: 'orders',
message: error.message,
cause: error,
});
}pnpm request PATCH orders/33333333-3333-4333-8333-333333333333 '{"note":null}'The response is 400 ORDER_LOCKED, and the confirmed order's note stays unchanged. A concurrent version conflict uses ABORTED (409); duplicate creation uses ALREADY_EXISTS (409). Do not classify every business restriction as a 409 conflict. The example seeds the confirmed order and does not provide a confirmation endpoint.
Reuse Repository error handling
The framework already maps recognized Repository errors. Let them propagate: WRITE_FORBIDDEN returns 403, RECORD_NOT_FOUND returns 404, and VERSION_CONFLICT returns 409. The Repository code becomes reason, with domain app.
An independent router that needs no domain-error translation can install:
import { Hono } from 'hono';
import { apiErrorHandler } from '@nocobase/app-server/router';
const router = new Hono();
router.onError(apiErrorHandler);The example test passes a real Repository a policy that prohibits updates. The order route returns 403 WRITE_FORBIDDEN and leaves the record unchanged. A separate bare-Hono test updates a missing record and receives 404 RECORD_NOT_FOUND. Run these scenarios with:
pnpm test -t 'Repository errors'Translate custom business exceptions
The service throws OrderMissingError, CustomerUnavailableError and OrderLockedError. The route translates them into HTTP errors, so the service needs no Hono context.
router.onError((error, c) => apiErrorHandler(toOrderApiError(error), c));toOrderApiError() handles only those three exceptions and returns unrecognized errors unchanged. apiErrorHandler() renders known errors and rethrows unknown exceptions to the outer application. The complete application then produces 500 INTERNAL / INTERNAL_ERROR without exposing a stack or internal cause. Do not wrap every exception as 400 or return failures with status 200.
A router tested on bare Hono does not have the application's 500 fallback. The example injects an update failure into a complete application to verify it: run pnpm test -t 'unexpected failures'. The normal server has no deliberate crash endpoint.
Source: business service, domain-error translation, Repository and unexpected-error tests.
Declare success and error responses
The responses in describeRoute() describe the API document. Lists use listResponse(Order, OrderListMeta); individual results use dataResponse(Order). For example, the update route declares:
responses: {
'200': dataResponse(Order),
'400': apiErrorResponse(
400,
'The customer is unavailable (CUSTOMER_UNAVAILABLE), or the order is confirmed (ORDER_LOCKED).',
),
'404': apiErrorResponse(404),
...apiErrorResponses,
}A route with apiValidator() automatically gets a 400 declaration for invalid input. The explicit 400 above adds business-failure descriptions. apiErrorResponses contains only 401, 403 and 500, for routes that check both authentication and permissions. Public endpoints should declare errors matching their actual behavior.
Run pnpm request GET swagger to find operations including ordersListOrders, ordersCreateOrder and ordersUpdateOrder. The list has pagination parameters and a generated validation-error declaration; the update operation also describes unavailable customers and confirmed orders.
These helpers do not set the actual HTTP status, validate runtime output or remove sensitive fields. Handlers still return the correct status. The service's toOrderView() explicitly selects public fields, and list rows and totals use the same permission policy. Annotating the response schema as z.ZodType<OrderView> aligns it with the service's view type, but type checking cannot replace runtime verification.
Source: route response declarations, response schemas.
Read error details and identify a request
ApiClientError.status is a numeric HTTP status. Read reason, domain and requestId directly. Field errors remain in payload.error.fieldViolations; validate that structure before reading it. The walkthrough's error summary uses:
import { ApiClientError } from '@nocobase/api-client';
import { z } from 'zod';
const ErrorDetails = z.object({
error: z.object({
fieldViolations: z.array(z.object({
field: z.string(),
description: z.string(),
reason: z.string().optional(),
})).optional(),
}),
});
export function errorSummary(error: unknown) {
if (!(error instanceof ApiClientError)) throw error;
const details = ErrorDetails.safeParse(error.payload);
return {
status: error.status,
reason: error.reason,
domain: error.domain,
requestId: error.requestId,
fields: details.success
? (details.data.error.fieldViolations ?? []).map(({ field, reason }) => ({ field, reason }))
: [],
};
}Run pnpm demo. An invalid quantity prints status: 400, reason: INVALID_INPUT and items.0.quantity in fields. Network exceptions have no server error response and should not be forced into ApiClientError; this helper rethrows other failures.
Map domain and reason to localized UI wording. Do not parse or directly display developer-facing message or field description strings. For form feedback and request state, see Data requests and state management.
Keep the requestId when investigating a problem and correlate it with server request logs. The complete application returns the same ID in the x-request-id header and error body; API Client prefers the header. This example enables request logs under storage/logs/.
Source: error summary, client requests.
Verify data behavior after failures
pnpm demo
pnpm testThe HTTP walkthrough checks valid requests, out-of-range pagination, invalid IDs, nested field errors, extra fields, unavailable customers, missing orders and attempts to edit confirmed orders. It rereads the order after refusals. Integration tests also compare database records before and after failures and verify that:
- Unauthenticated requests return 401. A reader receives 403 even for invalid fields or nonexistent IDs.
- Empty updates, whitespace-only values, excessive lengths and incorrect JSON types cause no order creation or update.
- An omitted note is preserved, and explicit null is persisted.
- Repository errors retain their standard classification; unexpected exceptions return an opaque 500.
- OpenAPI inputs and error declarations match endpoint behavior.
An HTTP error response does not undo already committed work. This example validates input and business rules before writing, and wraps writes in database transactions. Operations with multiple writes also need the correct transaction boundary. See Transactions.
Tests use isolated databases and do not reset walkthrough data. Repository refusals and unexpected exceptions use test-only injected conditions; see the example tests for their scope.
Example source and running
Source: request-validation. Use Node.js 24 and pnpm 11.7.0; the lockfile pins dependencies. This is an independent server application and does not require a historical project from another chapter.
git clone https://github.com/nocobase/docs-examples-v3.git
cd docs-examples-v3
git checkout 12e46abf29509039d988bab437de0aad8931f93e
cd apps/request-validation
pnpm install --frozen-lockfile
pnpm setup
pnpm startSetup generates local configuration, accounts and demo data. Open another terminal in the same application directory and run pnpm demo or the earlier pnpm request commands. The helper reads the account from storage/demo.json, so you do not need to copy a password. Select the reader with DEMO_ROLE=reader pnpm request ....
The server listens on port 13220 with the /main/api prefix. For another host address, set the same APP_PUBLIC_ORIGIN on setup, startup and request commands. SQLite is the example configuration; validation and error handling do not depend on SQLite. Other databases have not been verified with this example.
See the README for configuration, fixtures, checks and verification scope.