Server layout
Server layout
Section titled “Server layout”Callspec doesn’t require a particular folder layout. This is the split we recommend — each route, shared pred, and the registry stay easy to find and test.
- One route per file —
src/routes/getProductById.tsexportsgetProductById = route({ … }). Co-locategetProductById.spec.ts. - Keep
handlerinline in thatroute({ … })call soinput/ success return types flow from the preds — avoid extracting the handler +HandlerForunless you have a real reason (route). - Shared domain preds live under
src/schemas/and are imported by routes (output: product). Infer TS types withInfer<typeof product>when local data (e.g. fixtures) should match. Route-only wire shapes ({ id }, filters) stay in the route file. spec.tsis only the registry —spec({ meta, routes, exports?, authenticate? }). Import named routes; don’t redefine them there.index.tsonly mounts — Express +mountSpec(JSON parse is on by default). No route logic.
my-api/├── src/│ ├── index.ts # Express app — mountSpec on /v1│ ├── spec.ts # spec({ meta, routes, exports?, authenticate? })│ ├── auth.ts # optional — Authenticate<Ctx>│ ├── schemas/│ │ └── product.ts # shared domain preds│ └── routes/│ ├── getProductById.ts│ ├── getProductById.spec.ts│ └── listProducts.ts└── package.jsonShared schemas
Section titled “Shared schemas”import {predicates as p, Infer} from 'runtyp';
export const product = p.object({ id: p.string(), name: p.string(), priceCents: p.number(),});export type Product = Infer<typeof product>;
export const productList = p.object({ items: p.array(product), count: p.number(),});Routes
Section titled “Routes”import {route, err} from 'callspec';import {predicates as p} from 'runtyp';import {product, type Product} from '../schemas/product';
const products: Product[] = [ {id: 'sku-1', name: 'Widget', priceCents: 999}, {id: 'sku-2', name: 'Gadget', priceCents: 1299},];
export const getProductById = route({ input: p.object({id: p.string()}), output: product, meta: {summary: 'Get product by ID', tags: ['catalog']}, auth: 'none', handler: async (input, _ctx) => { const found = products.find((item) => item.id === input.id); if (!found) return err.NOT_FOUND(); return found; },});import {route} from 'callspec';import {predicates as p} from 'runtyp';import {productList} from '../schemas/product';
export const listProducts = route({ output: productList, meta: {summary: 'List products', tags: ['catalog']}, auth: 'none', handler: async (_input, _ctx) => ({ items: [{id: 'sku-1', name: 'Widget', priceCents: 999}], count: 1, }),});Registry and entrypoint
Section titled “Registry and entrypoint”import {spec} from 'callspec';import {product, productList} from './schemas/product';import {getProductById} from './routes/getProductById';import {listProducts} from './routes/listProducts';
export const api = spec({ meta: {title: 'My API', version: '1.0.0', intro: 'Product catalog with typed RPC.'}, routes: {getProductById, listProducts}, // optional — preds the frontend imports (forms, filters) exports: {product, productList},});import express from 'express';import {mountSpec} from 'callspec';import {api} from './spec';
const app = express();const router = express.Router();
mountSpec(router, api);app.use('/v1', router);
app.listen(3000);mountSpec parses application/json on this router. Do not add a host express.json() on the same router.
Auth: Authentication. Default mount URLs: mountSpec.