Skip to content

API Design

Ankit Upadhyay edited this page Aug 13, 2026 · 1 revision

API design

This page describes the conventions the openrunic API service follows: the middleware chain, the error contract, pagination, and how the OpenAPI document is produced. It is for anyone adding a route to apps/api or writing a client against it.

For what each endpoint currently does, see API service. For the FHIR surface specifically, see FHIR boundary.

Two surfaces, one app

The service exposes two HTTP surfaces from the same Hono app:

  • /fhir is the standards surface. It speaks FHIR R4, returns application/fhir+json, and renders errors as OperationOutcome. This is the contract third parties are meant to build against.
  • /bff/v0 is the internal surface for openrunic's own front ends. It returns plain JSON shaped for screens, and renders errors as RFC 9457 problem documents. The v0 is honest: it is not a stable contract yet.

Two paths are outside both: GET /healthz and GET /openapi.json.

The middleware chain

The order is declared as data in apps/api/src/middleware/chain.ts, so the test suite asserts it directly rather than inferring it from registration calls:

export const MIDDLEWARE_ORDER = ['request-id', 'authn', 'tenant-scope', 'policy', 'audit'] as const;

createApp() in apps/api/src/app.ts registers Hono's logger() (skipped when NODE_ENV is test) and secureHeaders(), then walks the chain returned by buildMiddlewareChain().

flowchart LR
    R[Request] --> L[logger]
    L --> S[secureHeaders]
    S --> A[request-id]
    A --> B[authn]
    B --> C[tenant-scope]
    C --> D[policy]
    D --> E[audit]
    E --> H[route handler]
    H --> E2[audit flush]
    E2 --> A2[echo x-request-id]
    A2 --> Resp[Response]
Loading
Stage File What it does
request-id apps/api/src/middleware/request-id.ts Sets requestId and responseFormat on the context. Accepts an inbound x-request-id only if it is at most 128 characters and matches /^[\x21-\x7e]+$/; otherwise generates a UUIDv7. Echoes the header back after the handler runs.
authn apps/api/src/middleware/authn.ts Resolves a bearer token to a Principal through an injected PrincipalResolver. A missing token and an unresolvable token produce the same 401. Public paths are matched exactly.
tenant-scope apps/api/src/middleware/tenant-scope.ts Sets tenantId from the principal, and only from the principal. If the x-openrunic-tenant header disagrees, the request is rejected with 403 rather than silently falling back.
policy apps/api/src/middleware/policy.ts Builds a PolicyContext from the principal's roles. It enforces nothing on its own; route guards consult it.
audit apps/api/src/middleware/audit.ts Creates a request-scoped AuditCollector and binds the repository registry to the tenant. Flushes the batched read event in a finally after the handler; a flush failure is reported through onFlushError and never rethrown.

Typed context variables are declared in apps/api/src/context.ts: requestId and responseFormat are always present; principal, tenantId, policy, audit, and repositories are optional because public routes run without them.

Error handling is not a middleware. It is app.onError(...), with app.notFound(...) throwing ApiError.notFound(...) so that 404s flow through the same boundary as every other error.

There is no rate limiting, CORS, CSRF, body-limit, compression, or timeout middleware in apps/api today. The only Hono built-ins in use are logger and secureHeaders.

The error contract

Every layer throws one type, ApiError (apps/api/src/errors.ts). The error handler picks the representation from responseFormat, which stage one decided from the request path. One error, two renderings.

The kind determines the status and the default title and FHIR issue code:

Kind Status Default title FHIR issue code
malformed-request 400 Malformed request invalid
unauthenticated 401 Authentication required login
forbidden 403 Not permitted forbidden
not-found 404 Not found not-found
conflict 409 Conflict duplicate
validation-failed 422 Validation failed invariant
not-implemented 501 Not implemented not-supported
internal-error 500 Internal error exception

Static factories cover each kind: ApiError.malformed, .unauthenticated, .forbidden, .notFound, .conflict, .validation(detail, issues), .notImplemented.

Problem documents on /bff/v0

Rendered by apps/api/src/http/problem.ts with content type application/problem+json:

{
  "type": "https://openrunic.org/problems/validation-failed",
  "title": "Validation failed",
  "status": 422,
  "detail": "The request body failed validation.",
  "instance": "/bff/v0/patients",
  "requestId": "01890000-0000-7000-8000-0000000004a1",
  "errors": [{ "path": "birthDate", "message": "Invalid date" }]
}

errors appears only when the error carries field issues. The document is a strict schema, and it is serialised with c.body(JSON.stringify(...)) rather than c.json so the media type survives.

OperationOutcome on /fhir

Rendered by apps/api/src/http/fhir.ts with content type application/fhir+json. One issue per field issue, carrying severity: 'error', the error's FHIR issue code, diagnostics from the message, and expression from the field path. When there are no field issues, a single issue is built from the error detail.

Every outcome gets one extra informational issue carrying the request id, so a caller can quote it in a bug report without reading response headers. Assembly goes through operationOutcome() in @openrunic/fhir, which compacts empty strings and empty arrays out of the result. A 401 additionally sets WWW-Authenticate: Bearer realm="openrunic".

Unhandled exceptions

Anything that is not an ApiError is coerced to internal-error with a fixed detail asking the caller to quote the request id. The original is logged server-side. No stack trace, driver message, or query text reaches the response body.

Validation status convention

Set in apps/api/src/http/validate.ts and applied consistently:

Failure Status
Query string does not validate 400
Path parameter does not validate 400
Body is not valid JSON 400
Body is valid JSON but fails the schema 422

Every schema is a Zod strictObject. An unknown key is a rejection, not a silent drop.

Pagination

Offset-based, not cursor-based. The reason is written into apps/api/src/schemas/pagination.ts: every list in the product is a screen with a pager and a total. Cursors are reserved for bulk export, where the access pattern is genuinely a stream.

DEFAULT_PAGE_SIZE is 25 and MAX_PAGE_SIZE is 100.

Internal surface. Query parameters are page (integer, minimum 1, default 1), pageSize (integer, 1 to 100, default 25), sort (a per-resource enum), and order (asc or desc). Patients sort by familyName, birthDate, or createdAt, defaulting to familyName. Appointments sort by start or createdAt, defaulting to start.

The envelope is uniform:

{
  "data": [],
  "page": { "page": 1, "pageSize": 25, "total": 0, "totalPages": 1 }
}

totalPages is Math.max(1, Math.ceil(total / pageSize)), so an empty result reports one page rather than zero. Pagers do not have to special-case emptiness.

FHIR surface. Parameters are _count (1 to 100, default 25) and _offset (default 0). _offset must be a multiple of _count or the request is rejected with 400. The response is a searchset Bundle carrying the total for the whole result set and self, next, and previous links. Link parameters are sorted for determinism, and an empty bundle omits entry entirely rather than emitting an empty array.

OpenAPI

The document is served at GET /openapi.json, unauthenticated, and built fresh on each request by buildOpenApiDocument(internalRouteContracts()). The version is OpenAPI 3.1.0.

There is no generator library. OpenAPI 3.1's schema object is JSON Schema 2020-12, so apps/api/src/openapi/spec.ts calls Zod's native z.toJSONSchema with target: 'draft-2020-12' and io: 'input', because a request spec describes what a client sends. A small override rewrites date nodes to { "type": "string", "format": "date-time" }.

Routes are declared as data. A RouteContract in apps/api/src/openapi/registry.ts carries the method, the braced path (/bff/v0/patients/{id}), an operationId, summary, tags, the required permission, and the parameter and body schemas. toHonoPath() converts the braced form to Hono's colon form, so the document and the router cannot describe different paths.

Two things worth knowing:

  • Each operation carries an x-openrunic-permission extension naming the permission the route requires. That makes the authorization model readable from the spec.
  • The security scheme is declared as bearer with bearerFormat: JWT. That describes the intended shape. The resolver running today accepts opaque tokens, so treat the format as forward-looking.

The document covers /bff/v0 only. The FHIR surface is described by its own CapabilityStatement at GET /fhir/metadata, and /healthz and /openapi.json are not in either.

The test suite asserts both directions: every contract corresponds to a route Hono actually registered, and every undocumented route is one the project has consciously left out of the document.

There is no Swagger UI, Scalar, or Redoc route, and no build step that writes a spec file to disk.

Adding a route

  1. Define request and response schemas as Zod strictObjects under apps/api/src/schemas/.
  2. Add a RouteContract to the registry with an operationId, tags, and the permission it requires.
  3. Implement the handler. Guard it with requirePermission(...), and with assertFacilityAccess(...) when the resource belongs to a facility.
  4. Read and write through the request-scoped repositories. Never take a tenant id as a parameter; see Multi-tenancy and isolation.
  5. Throw ApiError for every failure. Do not construct response bodies by hand.
  6. Write tests that drive the real app through app.request(...), including the 401, 403, and cross-tenant 404 cases.

Related pages

Clone this wiki locally