Paginate a List
A list endpoint needs three things a hand-rolled ?page=&size= parser tends to get wrong: a page size
ceiling (so a client can't ask for 100,000 rows), coercion (a URL query is always strings, so gte=2000
has to become a number before it reaches your repository), and a consistent response envelope.
@heximon/schema ships both halves as one runtime builder — QuerySchema for the request,
PaginatedResponseSchema for the response — over any Standard Schema.
Author the query schema
QuerySchema.create(shape, options) takes a field-shape (name → a leaf Standard Schema) and returns a
class that is both a validated query type and the StandardSchemaV1 value a route's .query()
references. Page mode (page/size) is the default; both fields are always present in the validated
output, defaulted and capped — no handler-side fallback.
A URL query is string-valued, so a numeric filter operand needs a coercing leaf, or a comparison like
priceCents: { gte: 2000 } arrives as the string "2000" and a bare z.number() rejects it:
import { QuerySchema } from "@heximon/schema";
import { z } from "zod";
const numericOperand = z.coerce.number();
export class BrowseProductsQuery extends QuerySchema.create(
{ name: z.string(), priceCents: numericOperand },
{ maxPageSize: 100 },
) {}
z.coerce.number() both validates and coerces — the validator writes the coerced number back into the
query, so a repository reading query.filter?.priceCents?.gte gets a real number, not the raw string.
Author the response schema
PaginatedResponseSchema.create(itemSchema) wraps a per-item schema in { data: item[], metadata: { queryCount } } — the total count alongside the current page, so a client can render "1,204 results"
without a second request.
import { PaginatedResponseSchema, Schema, SchemaObject } from "@heximon/schema";
import { z } from "zod";
export class ProductResponse extends SchemaObject({
id: z.string(),
name: z.string(),
priceCents: z.number(),
}) {}
export class ProductPageResponse extends Schema(
PaginatedResponseSchema.create(ProductResponse.schema),
) {}
Schema(...) wraps the framework-emitted validator in a constructible class, so ProductPageResponse is
usable as a route's responses entry (and emits a JSON Schema for OpenAPI) the same way any other DTO is.
Wire the route
Reference the query schema with .query() and the response with .responses() — same builder as any
other route.
import { Contract, Route } from "@heximon/contract";
import { BrowseProductsQuery, ProductPageResponse } from "./product.dto";
export class ProductsApi extends Contract({
prefix: "/products",
routes: {
browse: Route.get("/").query(BrowseProductsQuery).responses({ 200: ProductPageResponse }),
},
}) {}
Read the validated query in the handler
action.request.getValidatedQuery() returns the query already validated, coerced, and defaulted — the
handler passes page/size straight to a repository's paging method and returns the shape
PaginatedResponseSchema describes.
import type { Action, Controller } from "@heximon/http";
import { ProductsApi } from "./product.api";
import { ProductsRepository } from "./products.repository";
export class ProductsController implements Controller<ProductsApi> {
public constructor(private readonly products: ProductsRepository) {}
public async browse(action: Action<ProductsApi, "browse">) {
const query = await action.request.getValidatedQuery();
const { rows, total } = await this.products.getPage({ page: query.page, size: query.size }, query.filter);
return { data: rows, metadata: { queryCount: total } };
}
}
curl "http://localhost:3000/products?filter[priceCents][gte]=2000&page=2&size=10"
# → { "data": [...10 products...], "metadata": { "queryCount": 147 } }
curl "http://localhost:3000/products?size=99999"
# → 400 application/problem+json — "size must not exceed 100"
{ pagination: "offset" } instead and the validated query carries limit/offset
rather than page/size — same ceiling, defaulting, and coercion, for a repository that pages by offset.See also
- Validation —
SchemaObject/SchemaArray, and bringing your own validator. - Contracts — the full
Routebuilder, and binding a contract-mode controller. - Repository pattern — filtering/paging an
EntityQueryfrom a validated query, and mapping response-vocabulary field names to entity fields with aQueryMapperwhen they differ.
Cache an Expensive Call
Wrap a slow read with the Cache facade's getOrSet over a Storage tier, tag the entry for invalidation, and bust it after a write — MemoryStorage, Cache.invalidate, tags.
Protect Admin Routes
Gate an admin-only surface with JWTAuthMiddleware for authentication and AuthContext.isAllowed for a scope-guarded 401/403/200 ladder on inline routes.