API · REST + OpenAPI
A public REST API built on defineRestRoute — GET/POST/DELETE with query, path, and body parsing, scope guards, Idempotency-Key dedup, and an OpenAPI 3.1 spec + Swagger UI auto-generated from the route descriptors. Zero-infra boot.
A turnkey public REST API — plain raw-HTTP endpoints a third party calls with a URL + JSON, the opposite end from the reactive rpc/WebSocket surface (api-backend). Four defineRestRoute endpoints over a products catalog, scope-guarded writes, Idempotency-Key dedup, and an OpenAPI 3.1 spec + Swagger UI generated from the descriptors. Boots with zero infra (store: 'memory'). Template id: api-rest.
Scaffold
voltro create-project acme --api=api-restBoots on http://localhost:4000; open http://localhost:4000/docs for the Swagger UI.
What ships
apps/acme/api/
├── app.config.ts # restRoutes + openapiPlugin + apiKeyStrategy + idempotency
├── package.json # + @voltro/plugin-openapi
├── database/schema.ts # a plain `products` table (no mixins — the routes own auth)
├── lib/product.ts # shared wire Schema + row→wire mapper (one source of truth)
└── routes/v1/
├── products.list.route.tsx # GET /v1/products?limit=&cursor= (public, paginated)
├── products.get.route.tsx # GET /v1/products/:id (public, 404)
├── products.create.route.tsx # POST /v1/products (guarded + idempotent)
└── products.delete.route.tsx # DELETE /v1/products/:id (guarded)REST routes are the one primitive that is NOT auto-discovered — they're registered explicitly via restRoutes in app.config.ts.
A route — query / path / body in, typed JSON out
// routes/v1/products.create.route.tsx
import { defineRestRoute, requireScope } from '@voltro/protocol/rest'
import { Schema } from 'effect'
import { Product, toProduct } from '../../lib/product'
export default defineRestRoute({
method: 'POST',
path: '/v1/products',
input: Schema.Struct({ // the desugar parses { query?, params?, body? }
body: Schema.Struct({
name: Schema.NonEmptyString,
priceCents: Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(0)),
}),
}),
output: Product,
summary: 'Create a product', // OpenAPI metadata
guards: [requireScope('products:write')], // → 403 when the subject lacks the scope
handler: async ({ body }, ctx) => { /* ctx.subject + ctx.store injected by serve */ },
})The desugar, per request: method gate (405), input decode (400 on a bad shape), guards (403), await handler, output encode (200 JSON). A handler may throw { status, message } for a specific code (the get/delete routes throw { status: 404 }).
OpenAPI + Swagger UI — zero hand-written docs
// app.config.ts
import { openapiPlugin } from '@voltro/plugin-openapi'
const routes = [listProducts, getProduct, createProduct, deleteProduct]
export default {
type: 'api' as const, name: 'AcmeApi', store: 'memory' as const,
plugins: [openapiPlugin({ routes, info: { title: 'Acme API', version: '1.0.0' } })],
restRoutes: routes, // the SAME array mounts the routes AND documents them
idempotency: true, // Idempotency-Key dedup for the mutating routes
}openapiPlugin serves GET /openapi.json (the spec) + GET /docs (Swagger UI), built from the descriptors — :id paths render as OpenAPI {id}, and refined types (NonEmptyString, …) resolve under components.schemas. The route descriptors ARE the spec; there is nothing to hand-maintain.
Auth — the demo key is DEV-ONLY
The guarded routes gate on a scope. app.config.ts wires an apiKeyStrategy so Authorization: Bearer <key> resolves to a scoped Subject, and ships ONE hardcoded demo key (restdemo_devkey → products:read + products:write) so the write path works on first boot:
import { apiKeyStrategy } from '@voltro/protocol/apikey'
// sha256('restdemo_devkey') → a fixed scoped Subject. DEV-ONLY.
const DEMO_KEY_SHA256 = 'c891…41cd'
auth: { strategies: [apiKeyStrategy({
prefix: 'restdemo_',
resolveKey: (hash) => hash === DEMO_KEY_SHA256
? { id: 'svc_demo', tenantId: 'acme', scopes: ['products:read', 'products:write'] }
: null,
})] }Before deploying, delete the demo constant and look the token hash up against your own key store (store key hashes, never raw tokens) — or flip on the framework's first-class API keys (apiKeys: true) for admin-gated issue/list/revoke against _voltro_api_keys.
Try it (curl)
# The spec + interactive docs
curl -s http://localhost:4000/openapi.json | jq '.info, (.paths | keys)'
open http://localhost:4000/docs
# Public reads — no auth
curl -s http://localhost:4000/v1/products # → { "data": [], "nextCursor": null }
# Guarded create — the demo key carries products:write
curl -s -X POST http://localhost:4000/v1/products \
-H 'Authorization: Bearer restdemo_devkey' -H 'content-type: application/json' \
-d '{"name":"Widget","priceCents":1999}' # → 201 the created product
# Without the key → 403 (the guard rejects)
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/v1/products \
-H 'content-type: application/json' -d '{"name":"x","priceCents":1}' # → 403
# Idempotency — same key header twice → the first response replayed, not re-inserted
curl -s -X POST http://localhost:4000/v1/products \
-H 'Authorization: Bearer restdemo_devkey' -H 'Idempotency-Key: order-42' \
-H 'content-type: application/json' -d '{"name":"Once","priceCents":500}'Going to production
| Want… | Do |
|---|---|
| Durable data | store: 'memory' → store: 'postgres' (data survives restarts) |
| Real API keys | delete the demo key; hash-lookup your own store, or set apiKeys: true |
| Multi-tenant scoping | add tenant() to the table + scope each handler by the key's tenantId |
| Versioning / sunset | a descriptor's deprecated sets a Deprecation: header; sunset sets Sunset: + 410s past the date |
Anti-patterns
- Forgetting to register the route. REST routes are NOT auto-discovered — add every
defineRestRouteto therestRoutesarray (and theopenapiPluginroutesarray) inapp.config.ts, or it's neither mounted nor documented. - Shipping the DEV-ONLY demo key. It's a sample credential, like a README password — delete it and back the strategy with a real hash store before deploying.
- Reaching for REST when an action fits. A REST route is for EXTERNAL HTTP callers. For your own reactive frontend, a mutation / action over the rpc WebSocket is typed end-to-end and auto-optimistic.
- Expecting
Idempotency-Keyon the rpc transport. It rides the HTTP surface only; double-submit from your own frontend is a UI concern (disable the button), not server idempotency.
See also
- App templates — the full catalogue
@voltro/plugin-openapi— the spec + Swagger generatorapi-auth— real user sessions (the rpc-side auth counterpart)