Skip to content

API Service

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

API service

This page lists what the API serves today, endpoint by endpoint, and what it does not. It is for anyone writing a client or picking up an unimplemented aggregate.

For the conventions behind these endpoints, see API design.

Running it

pnpm --filter api dev      # tsx watch, port 4000
pnpm --filter api test     # vitest run --coverage
pnpm --filter api build    # tsc
pnpm --filter api start    # node dist/index.js

pnpm start with NODE_ENV=production throws at construction by design, because index.ts builds the app with in-memory defaults and the production wiring assertion refuses them. See Security model.

Layout

apps/api/src/
  app.ts          createApp(): wires the chain, the routers, one error boundary
  index.ts        process entry: parseEnv, serve
  env.ts          the environment schema
  context.ts      typed Hono context variables
  errors.ts       ApiError and the kind-to-status map
  middleware/     the chain, in the order the design fixes
  routes/         internal REST, one router per aggregate
  fhir/           the FHIR R4 boundary: registry, metadata, search, bundles
  repositories/   data access behind an interface; in-memory and Prisma
  audit/          request-scoped collector, in-transaction sink
  policy/         the permission catalogue and role bundles
  schemas/        request and response contracts, in Zod
  openapi/        the spec, generated from those same Zod contracts
  http/           the two error representations and validation helpers

Endpoints

Public

Method Path Returns
GET /healthz { "status": "ok", "service": "openrunic-api" }
GET /openapi.json OpenAPI 3.1 document for /bff/v0
GET /fhir/metadata FHIR CapabilityStatement

FHIR R4

Method Path Permission Notes
GET /fhir/Patient patient.read searchset Bundle. Unsupported parameters are rejected, not ignored.
GET /fhir/Patient/{id} patient.read A cross-tenant miss is reported as 404, never 403.
POST /fhir/Patient patient.write 201 with an absolute Location header.
ALL /fhir/* chain only Catch-all 404 as an OperationOutcome.

Patient is the only routed FHIR resource. There is no update or delete interaction. The declared interactions are read, search-type, and create, and the CapabilityStatement is generated from the same registry that validates search parameters, so it cannot advertise something the server will refuse.

Twenty-three mapper pairs exist in packages/fhir covering the rest of the clinical, scheduling, and financial resources. They are tested but not yet reachable over HTTP. See FHIR boundary.

Internal surface

Base path /bff/v0. Explicitly unstable; it changes with the screens it serves.

Patients

Method Path Operation Permission
GET /bff/v0/patients listPatients patient.read
GET /bff/v0/patients/{id} readPatient patient.read
POST /bff/v0/patients createPatient patient.write
PATCH /bff/v0/patients/{id} updatePatient patient.write

Search accepts a free-text q across given, family, preferred name, and MRN, plus mrn, family, given, birthDate, and active. Sorts by familyName, birthDate, or createdAt.

Appointments

Method Path Operation Permission
GET /bff/v0/appointments listAppointments appointment.read
GET /bff/v0/appointments/{id} readAppointment appointment.read
POST /bff/v0/appointments createAppointment appointment.write
PATCH /bff/v0/appointments/{id} updateAppointment appointment.write

Every appointment route additionally calls assertFacilityAccess. On create, the facility check runs before the write, so an unauthorised booking never reaches the database. On read and update it runs against the row that was found.

Range filtering is from inclusive and to exclusive. Sorts by start or createdAt, defaulting to start.

Reserved aggregates

Seven aggregates are mounted, authenticated, authorised, and then answer 501: encounters, orders, results, claims, payments, tasks, and forms. Each mounts four routes, so 28 routes exist in this state.

The ordering is the interesting part and is asserted by tests. An anonymous caller gets 401. A caller with the wrong role gets 403 and an audit record of the denial. Only a caller who would otherwise have been allowed sees the 501, whose detail names the workstream that owns the aggregate.

That means the status code never tells someone who could not have used a feature whether it exists.

Not present at all

No route and no stub: practitioners, facilities and organisations, users, coverage, documents, medications, allergies, conditions, immunizations, observations, schedules and slots, and any authentication or token endpoint.

Authentication for development

The static resolver accepts four synthetic tokens across two synthetic tenants:

Token Role Tenant
dev-clinician-a clinician A
dev-frontdesk-a front desk A
dev-biller-a biller A
dev-clinician-b clinician B

Having a principal in a second tenant makes cross-tenant behaviour testable by hand:

# Read a patient in tenant A
curl -H 'Authorization: Bearer dev-clinician-a' \
  http://localhost:4000/bff/v0/patients/<id>

# The same id with tenant B's token returns 404, not 403
curl -H 'Authorization: Bearer dev-clinician-b' \
  http://localhost:4000/bff/v0/patients/<id>

This resolver performs no signature verification and is a development fixture. See Security model.

Repositories

Data access sits behind interfaces in apps/api/src/repositories/types.ts, with two implementations. The in-memory one backs the tests. The Prisma one runs at runtime.

The interfaces take no tenant parameter. RepositoryRegistry.forRequest({ tenantId, audit }) binds a tenant and an audit collector per request, so a handler has no way to name another organisation.

Two Prisma constraints follow from the tenant client extension and are documented in db-port.ts: reads use findFirst rather than findUnique, and writes use updateMany rather than update. The extension rewrites where into a compound AND predicate, which is not a legal filter for the unique-argument variants.

Configuration

Two variables, validated by a Zod schema in apps/api/src/env.ts:

Variable Type Default
PORT integer, 1 to 65535 4000
NODE_ENV development, test, or production development

Validation failure throws a message naming the offending variables and never echoing their values.

NODE_ENV is additionally read directly in app.ts for two purposes: gating the production wiring assertion, and skipping request logging under test.

There is no .env loader in apps/api. DATABASE_URL is read by @openrunic/database, not by the API.

Testing

Sixteen suites under apps/api/src/__tests__/, covering the app, audit, environment, errors, FHIR, middleware, OpenAPI, policy, both repository implementations, the patient and appointment routers, the stub behaviour, schemas, and tenant isolation.

Everything drives the real application through app.request(...) against the in-memory repositories. No database, no port binding, no HTTP server. That is what makes the suite fast enough to shard.

Coverage uses the istanbul provider with src/index.ts excluded as entry wiring. There are no thresholds in the vitest config; floors are enforced by CI on the merged coverage map.

Related pages

Clone this wiki locally