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-rest

Boots 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_devkeyproducts: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 defineRestRoute to the restRoutes array (and the openapiPlugin routes array) in app.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-Key on 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