mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
6c3b8be099
commit
57b8fd8ad9
10 files changed
+809
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user