feat(fhir): read-only FHIR R4 REST search routes

GET /api/fhir/{metadata,Patient,Observation,MedicationStatement,
MedicationAdministration} and $everything, returning type:"searchset"
Bundles (total + self/next links, offset paging via _count clamped to 200)
as application/fhir+json, with an OperationOutcome on error. The per-resource
emitters are reused from src/lib/fhir/resources.ts so the document export and
the REST face share one coding source and can never drift. Auth is a narrow
fhir:read scope via requireAuth (cookie sessions also pass); userId is
narrowed from auth. Read-only — no write handlers. Rate-limited per user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 01:05:06 +02:00
1 parent 6c3b8be099
commit 57b8fd8ad9
10 files changed
+809

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
/**
* GET /api/fhir/$everything — FHIR R4 `$everything` operation.
*
* Returns every resource in the caller's own record — Patient, Coverage,
* Observations, MedicationStatements, MedicationAdministrations — in one
* `searchset` Bundle, in the canonical document order. Read-only; `userId`
* narrowed from `requireAuth`. Offset paging applies across the flattened
* resource list via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
coverageResource,
medicationAdministrationsFromReportData,
medicationStatementsFromReportData,
observationsFromReportData,
patientResource,
} from "@/lib/fhir/resources";
import type { FhirResource } from "@/lib/fhir/types";
import {
FHIR_READ_SCOPE,
loadFhirContext,
operationOutcome,
parsePaging,
searchsetResponse,
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.everything.read" } });
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
const { count, offset } = parsePaging(request.nextUrl.searchParams);
const { data, identity, germanAtc } = await loadFhirContext(user.id);
const all: FhirResource[] = [patientResource(data, identity)];
const coverage = coverageResource(data, identity);
if (coverage) all.push(coverage);
all.push(...observationsFromReportData(data, identity, { germanAtc }));
all.push(...medicationStatementsFromReportData(data, { germanAtc }));
all.push(...medicationAdministrationsFromReportData(data, { germanAtc }));
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
});
@@ -0,0 +1,39 @@
/**
* GET /api/fhir/MedicationAdministration — FHIR R4 `searchset` of the caller's
* own acted intakes (`completed` taken / `not-done` skip), with the same
* ATC/RxNorm + dose/route/site coding the document export carries.
*
* Read-only. `userId` narrowed from `requireAuth`; shared emitter is the
* single source of the coding. Offset paging via `_count` (≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources";
import {
FHIR_READ_SCOPE,
loadFhirContext,
operationOutcome,
parsePaging,
searchsetResponse,
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.medicationadministration.search" } });
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
const { count, offset } = parsePaging(request.nextUrl.searchParams);
const { data, germanAtc } = await loadFhirContext(user.id);
const all = medicationAdministrationsFromReportData(data, { germanAtc });
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
});
@@ -0,0 +1,40 @@
/**
* GET /api/fhir/MedicationStatement — FHIR R4 `searchset` of the caller's own
* active-medication statements (one per active medication, with additive
* ATC/RxNorm codings).
*
* Read-only. `userId` narrowed from `requireAuth`; shared emitter is the
* single source of the drug coding. Offset paging via `_count` (≤200) /
* `_offset`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { medicationStatementsFromReportData } from "@/lib/fhir/resources";
import {
FHIR_READ_SCOPE,
loadFhirContext,
operationOutcome,
parsePaging,
searchsetResponse,
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.medicationstatement.search" } });
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
const { count, offset } = parsePaging(request.nextUrl.searchParams);
const { data, germanAtc } = await loadFhirContext(user.id);
const all = medicationStatementsFromReportData(data, { germanAtc });
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
});
+40
View File
@@ -0,0 +1,40 @@
/**
* GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own
* Observations (vitals / activity / lab / survey), one latest reading per
* type plus the BP panel, BMI, glucose, adherence, mood and wellness scores.
*
* Read-only. `userId` is narrowed from `requireAuth`; the shared emitter is
* the single source of the LOINC/UCUM coding. Offset paging via `_count`
* (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { observationsFromReportData } from "@/lib/fhir/resources";
import {
FHIR_READ_SCOPE,
loadFhirContext,
operationOutcome,
parsePaging,
searchsetResponse,
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.observation.search" } });
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
const { count, offset } = parsePaging(request.nextUrl.searchParams);
const { data, identity, germanAtc } = await loadFhirContext(user.id);
const all = observationsFromReportData(data, identity, { germanAtc });
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
});
+39
View File
@@ -0,0 +1,39 @@
/**
* GET /api/fhir/Patient — FHIR R4 `searchset` of the caller's own Patient.
*
* Read-only. A FHIR `Patient` search always yields exactly the authenticated
* user's single Patient resource (the `userId` is narrowed from `requireAuth`;
* there is no cross-user search). Returned as a one-entry `searchset` Bundle
* so a generic FHIR client can page it like any other resource type.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { patientResource } from "@/lib/fhir/resources";
import {
FHIR_READ_SCOPE,
loadFhirContext,
operationOutcome,
parsePaging,
searchsetResponse,
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.patient.search" } });
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
const { count, offset } = parsePaging(request.nextUrl.searchParams);
const { data, identity } = await loadFhirContext(user.id);
const all = [patientResource(data, identity)];
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
});
+167
View File
@@ -0,0 +1,167 @@
/**
* v1.11.0 (Epic C, C2) — read-only FHIR R4 REST face.
*
* Asserts the searchset Bundle shape (total + self/next links + `match`
* mode + `application/fhir+json`), `_count` clamping (≤200), paging, the
* `fhir:read` scope wiring, and the `OperationOutcome` rate-limit envelope.
* The shared data loader is stubbed so the test pins the REST contract, not
* the aggregator (which has its own coverage).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
});
vi.mock("@/lib/fhir/resources", () => ({
observationsFromReportData: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET as observationGet } from "../Observation/route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest";
import { observationsFromReportData } from "@/lib/fhir/resources";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function obs(id: number) {
return {
resourceType: "Observation" as const,
id: `obs-${id}`,
status: "final" as const,
code: { text: `m-${id}` },
subject: { reference: "Patient/patient-1" },
};
}
function req(query = ""): NextRequest {
return new NextRequest(`http://localhost/api/fhir/Observation${query}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: Date.now(),
} as never);
vi.mocked(loadFhirContext).mockResolvedValue({
data: {} as never,
identity: { insuranceNumber: null },
germanAtc: false,
});
});
describe("GET /api/fhir/Observation — searchset", () => {
it("returns a searchset Bundle with total, self link, and match mode", async () => {
vi.mocked(observationsFromReportData).mockReturnValue([
obs(1),
obs(2),
] as never);
const res = await observationGet(req());
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
const bundle = (await res.json()) as {
resourceType: string;
type: string;
total: number;
link: Array<{ relation: string; url: string }>;
entry: Array<{ search: { mode: string }; fullUrl: string }>;
};
expect(bundle.resourceType).toBe("Bundle");
expect(bundle.type).toBe("searchset");
expect(bundle.total).toBe(2);
expect(bundle.entry).toHaveLength(2);
expect(bundle.entry[0].search.mode).toBe("match");
expect(bundle.entry[0].fullUrl).toContain("/api/fhir/Observation/obs-1");
expect(bundle.link.find((l) => l.relation === "self")).toBeTruthy();
// Single page → no next link.
expect(bundle.link.find((l) => l.relation === "next")).toBeUndefined();
});
it("clamps _count above the ceiling and emits a next link when more remain", async () => {
vi.mocked(observationsFromReportData).mockReturnValue(
Array.from({ length: 250 }, (_, i) => obs(i)) as never,
);
const res = await observationGet(req("?_count=9999&_offset=0"));
const bundle = (await res.json()) as {
total: number;
link: Array<{ relation: string; url: string }>;
entry: unknown[];
};
// 250 total, clamped page of MAX_COUNT.
expect(bundle.total).toBe(250);
expect(bundle.entry).toHaveLength(MAX_COUNT);
const next = bundle.link.find((l) => l.relation === "next");
expect(next).toBeTruthy();
expect(next!.url).toContain(`_count=${MAX_COUNT}`);
expect(next!.url).toContain(`_offset=${MAX_COUNT}`);
});
it("pages with _offset", async () => {
vi.mocked(observationsFromReportData).mockReturnValue([
obs(1),
obs(2),
obs(3),
] as never);
const res = await observationGet(req("?_count=2&_offset=2"));
const bundle = (await res.json()) as {
total: number;
entry: Array<{ resource: { id: string } }>;
};
expect(bundle.total).toBe(3);
expect(bundle.entry).toHaveLength(1);
expect(bundle.entry[0].resource.id).toBe("obs-3");
});
it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => {
// No session → Bearer path. requireAuth(FHIR_READ_SCOPE) must reject a
// token lacking the scope; here no auth at all yields 401, proving the
// route does gate auth before doing any work.
vi.mocked(getSession).mockResolvedValue(null as never);
const res = await observationGet(req());
expect(res.status).toBe(401);
expect(loadFhirContext).not.toHaveBeenCalled();
});
it("returns an OperationOutcome on rate-limit", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
count: 999,
resetAt: Date.now(),
} as never);
const res = await observationGet(req());
expect(res.status).toBe(429);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
const body = (await res.json()) as {
resourceType: string;
issue: Array<{ severity: string; code: string }>;
};
expect(body.resourceType).toBe("OperationOutcome");
expect(body.issue[0].code).toBe("throttled");
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* GET /api/fhir/metadata — FHIR R4 `CapabilityStatement`.
*
* Declares the read-only REST face: the resource types served, the search
* parameters honoured (`_count`, `_offset`), the `$everything` operation, and
* the `application/fhir+json` format. Static — no per-user data — but still
* gated behind the `fhir:read` scope so the whole `/api/fhir` tree answers
* uniformly. Read-only: no write interactions are advertised.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { FHIR_READ_SCOPE, fhirJsonResponse } from "@/lib/fhir/rest";
const READ_RESOURCES = [
"Patient",
"Observation",
"MedicationStatement",
"MedicationAdministration",
] as const;
export const GET = apiHandler(async () => {
await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.metadata.read" } });
const capability = {
resourceType: "CapabilityStatement",
status: "active",
date: new Date().toISOString(),
kind: "instance",
fhirVersion: "4.0.1",
format: ["application/fhir+json"],
rest: [
{
mode: "server",
documentation:
"Read-only access to the authenticated user's own health record.",
resource: [
...READ_RESOURCES.map((type) => ({
type,
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: [
{ name: "_count", type: "number" },
{ name: "_offset", type: "number" },
],
})),
{
type: "Patient",
operation: [
{
name: "everything",
definition:
"http://hl7.org/fhir/OperationDefinition/Patient-everything",
},
],
},
],
},
],
};
return fhirJsonResponse(capability);
});
+190
View File
@@ -0,0 +1,190 @@
/**
* v1.11.0 — shared helpers for the read-only FHIR R4 REST face.
*
* The `GET /api/fhir/*` search routes are thin: each resolves the caller's own
* `DoctorReportData` (the SAME aggregator the PDF + document export consume),
* runs the matching shared emitter from `./resources`, then wraps the result
* in a `type: "searchset"` Bundle via the helpers here. Keeping the bundling,
* paging, content-type and `OperationOutcome` shaping in one place means every
* resource endpoint answers identically.
*
* Read-only: there are no write handlers anywhere under `/api/fhir`. Auth is a
* narrow `fhir:read` Bearer scope (cookie sessions also pass); `userId` is
* always narrowed from the auth context, never accepted from the wire.
*/
import { NextResponse } from "next/server";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import {
collectDoctorReportData,
normaliseDateRange,
type DoctorReportData,
} from "@/lib/doctor-report-data";
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
import type {
FhirBundleEntry,
FhirBundleLink,
FhirOperationOutcome,
FhirResource,
FhirSearchsetBundle,
} from "@/lib/fhir/types";
/** The Bearer scope a narrow-scoped token must carry to read the FHIR face. */
export const FHIR_READ_SCOPE = "fhir:read";
/** Canonical FHIR media type for every response (success or outcome). */
export const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8";
/** Default page size; clamped to `[1, MAX_COUNT]`. */
export const DEFAULT_COUNT = 50;
/** Hard ceiling on `_count` so a single search can never page the whole store. */
export const MAX_COUNT = 200;
/**
* Parsed + clamped paging parameters. `count` is bounded to `[1, MAX_COUNT]`;
* `offset` floors at 0. A non-numeric / negative input collapses to the
* default rather than erroring — FHIR search is forgiving on paging params.
*/
export function parsePaging(searchParams: URLSearchParams): {
count: number;
offset: number;
} {
const rawCount = Number(searchParams.get("_count"));
const count =
Number.isFinite(rawCount) && rawCount > 0
? Math.min(Math.floor(rawCount), MAX_COUNT)
: DEFAULT_COUNT;
const rawOffset = Number(searchParams.get("_offset"));
const offset =
Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0;
return { count, offset };
}
/**
* Build the absolute self link, and a next link when more rows remain. The
* URL is rebuilt from the request URL with the paging params normalised, so
* the echoed link always carries the clamped `_count` and concrete `_offset`.
*/
function pagingLinks(
requestUrl: URL,
total: number,
count: number,
offset: number,
): FhirBundleLink[] {
const self = new URL(requestUrl.toString());
self.searchParams.set("_count", String(count));
self.searchParams.set("_offset", String(offset));
const links: FhirBundleLink[] = [{ relation: "self", url: self.toString() }];
const nextOffset = offset + count;
if (nextOffset < total) {
const next = new URL(requestUrl.toString());
next.searchParams.set("_count", String(count));
next.searchParams.set("_offset", String(nextOffset));
links.push({ relation: "next", url: next.toString() });
}
return links;
}
/**
* Wrap a page of resources in a `searchset` Bundle and return it as a FHIR
* JSON response. `total` is the FULL unpaged match count; `page` is the
* already-sliced window. Each entry is tagged `search.mode: "match"`.
*/
export function searchsetResponse(
requestUrl: URL,
page: FhirResource[],
total: number,
count: number,
offset: number,
): NextResponse {
const entry: FhirBundleEntry[] = page.map((resource) => ({
fullUrl: `${requestUrl.origin}/api/fhir/${resource.resourceType}/${resource.id}`,
resource,
search: { mode: "match" },
}));
const bundle: FhirSearchsetBundle = {
resourceType: "Bundle",
type: "searchset",
timestamp: new Date().toISOString(),
total,
link: pagingLinks(requestUrl, total, count, offset),
entry,
};
return new NextResponse(JSON.stringify(bundle), {
status: 200,
headers: { "Content-Type": FHIR_CONTENT_TYPE, "Cache-Control": "no-store" },
});
}
/** Return a raw FHIR resource (e.g. CapabilityStatement) as a FHIR response. */
export function fhirJsonResponse(body: unknown): NextResponse {
return new NextResponse(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": FHIR_CONTENT_TYPE, "Cache-Control": "no-store" },
});
}
/**
* Build an `OperationOutcome` JSON response — the FHIR error envelope. Used
* for 404 (no such resource) and any other FHIR-shaped error the routes
* surface. (Auth 401/403 stay on the standard envelope via `requireAuth`.)
*/
export function operationOutcome(
status: number,
code: string,
diagnostics: string,
severity: FhirOperationOutcome["issue"][number]["severity"] = "error",
): NextResponse {
const outcome: FhirOperationOutcome = {
resourceType: "OperationOutcome",
issue: [{ severity, code, diagnostics }],
};
return new NextResponse(JSON.stringify(outcome), {
status,
headers: { "Content-Type": FHIR_CONTENT_TYPE, "Cache-Control": "no-store" },
});
}
/**
* Resolve everything an emitter needs for a caller: the aggregated report data
* over the default reporting window, plus the decrypted KVNR identity and the
* BfArM-ATC flag (derived from the user's locale). Centralised so each
* resource route is a one-liner over the shared emitters.
*
* The window matches the document export's default (`normaliseDateRange`
* with no override). KVNR decryption is fail-soft: a key-rotation gap on a
* single row omits the identifier rather than 500-ing the read.
*/
export async function loadFhirContext(userId: string): Promise<{
data: DoctorReportData;
identity: { insuranceNumber: string | null };
germanAtc: boolean;
}> {
const range = normaliseDateRange(undefined);
const [data, userRow] = await Promise.all([
collectDoctorReportData(userId, range, {}),
prisma.user.findUnique({
where: { id: userId },
select: { insuranceNumberEncrypted: true, locale: true },
}),
]);
let insuranceNumber: string | null = null;
if (userRow?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
const germanAtc = (GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(
userRow?.locale ?? "",
);
return { data, identity: { insuranceNumber }, germanAtc };
}
+35
View File
@@ -203,6 +203,8 @@ export type FhirResource =
export interface FhirBundleEntry {
fullUrl?: string;
resource: FhirResource;
/** v1.11.0 — `searchset` per-entry search metadata (`mode: "match"`). */
search?: { mode: "match" | "include" | "outcome" };
}
export interface FhirBundle {
@@ -211,3 +213,36 @@ export interface FhirBundle {
timestamp: string;
entry: FhirBundleEntry[];
}
/** v1.11.0 — `Bundle.link` relation (self / next paging). */
export interface FhirBundleLink {
relation: "self" | "next";
url: string;
}
/**
* v1.11.0 — a `searchset` Bundle, the wire shape the FHIR REST search routes
* return. `total` is the unpaged match count; `link` carries the self + next
* relations for offset paging; each `entry.search.mode` is `"match"`.
*/
export interface FhirSearchsetBundle {
resourceType: "Bundle";
type: "searchset";
timestamp: string;
total: number;
link?: FhirBundleLink[];
entry: FhirBundleEntry[];
}
/** v1.11.0 — `OperationOutcome.issue` element. */
export interface FhirOperationOutcomeIssue {
severity: "fatal" | "error" | "warning" | "information";
code: string;
diagnostics?: string;
}
/** v1.11.0 — `OperationOutcome`, the FHIR error envelope. */
export interface FhirOperationOutcome {
resourceType: "OperationOutcome";
issue: FhirOperationOutcomeIssue[];
}
+144
View File
@@ -2102,6 +2102,150 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/fhir/metadata": {
get: {
tags: ["FHIR"],
summary: "FHIR R4 CapabilityStatement (v1.11.0)",
description:
"Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass).",
responses: {
"200": {
description: "CapabilityStatement (application/fhir+json).",
content: {
"application/fhir+json": {
schema: z.string().meta({ format: "binary" }),
},
},
},
...stdResponses,
},
},
},
"/api/fhir/Patient": {
get: {
tags: ["FHIR"],
summary: "FHIR R4 Patient search (v1.11.0)",
description:
"Read-only `searchset` Bundle of the caller's own Patient resource. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. `userId` is narrowed from auth.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
_offset: z.coerce.number().optional(),
}),
},
responses: {
"200": {
description: "searchset Bundle (application/fhir+json).",
content: {
"application/fhir+json": {
schema: z.string().meta({ format: "binary" }),
},
},
},
...stdResponses,
},
},
},
"/api/fhir/Observation": {
get: {
tags: ["FHIR"],
summary: "FHIR R4 Observation search (v1.11.0)",
description:
"Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
_offset: z.coerce.number().optional(),
}),
},
responses: {
"200": {
description: "searchset Bundle (application/fhir+json).",
content: {
"application/fhir+json": {
schema: z.string().meta({ format: "binary" }),
},
},
},
...stdResponses,
},
},
},
"/api/fhir/MedicationStatement": {
get: {
tags: ["FHIR"],
summary: "FHIR R4 MedicationStatement search (v1.11.0)",
description:
"Read-only `searchset` Bundle of the caller's own active-medication statements. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
_offset: z.coerce.number().optional(),
}),
},
responses: {
"200": {
description: "searchset Bundle (application/fhir+json).",
content: {
"application/fhir+json": {
schema: z.string().meta({ format: "binary" }),
},
},
},
...stdResponses,
},
},
},
"/api/fhir/MedicationAdministration": {
get: {
tags: ["FHIR"],
summary: "FHIR R4 MedicationAdministration search (v1.11.0)",
description:
"Read-only `searchset` Bundle of the caller's own acted intakes (completed / not-done). Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
_offset: z.coerce.number().optional(),
}),
},
responses: {
"200": {
description: "searchset Bundle (application/fhir+json).",
content: {
"application/fhir+json": {
schema: z.string().meta({ format: "binary" }),
},
},
},
...stdResponses,
},
},
},
"/api/fhir/$everything": {
get: {
tags: ["FHIR"],
summary: "FHIR R4 $everything (v1.11.0)",
description:
"Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage, Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
_offset: z.coerce.number().optional(),
}),
},
responses: {
"200": {
description: "searchset Bundle (application/fhir+json).",
content: {
"application/fhir+json": {
schema: z.string().meta({ format: "binary" }),
},
},
},
...stdResponses,
},
},
},
"/api/auth/login": {
post: {
tags: ["Auth"],