-
Notifications
You must be signed in to change notification settings - Fork 1
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.
The service exposes two HTTP surfaces from the same Hono app:
-
/fhiris the standards surface. It speaks FHIR R4, returnsapplication/fhir+json, and renders errors asOperationOutcome. This is the contract third parties are meant to build against. -
/bff/v0is the internal surface for openrunic's own front ends. It returns plain JSON shaped for screens, and renders errors as RFC 9457 problem documents. Thev0is honest: it is not a stable contract yet.
Two paths are outside both: GET /healthz and GET /openapi.json.
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]
| 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.
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.
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.
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".
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.
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.
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.
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-permissionextension 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.
- Define request and response schemas as Zod
strictObjects underapps/api/src/schemas/. - Add a
RouteContractto the registry with anoperationId, tags, and the permission it requires. - Implement the handler. Guard it with
requirePermission(...), and withassertFacilityAccess(...)when the resource belongs to a facility. - Read and write through the request-scoped repositories. Never take a tenant id as a parameter; see Multi-tenancy and isolation.
- Throw
ApiErrorfor every failure. Do not construct response bodies by hand. - Write tests that drive the real app through
app.request(...), including the 401, 403, and cross-tenant 404 cases.
openrunic is an open-source operating system for human health. Pre-alpha: do not run it in production, and never put real patient data into it.
Repository · Licence (AGPL-3.0-only) · Security policy · Contributing · Code of conduct
Where this wiki and the repository disagree, the repository is right.