mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(api): declare the FHIR refusals as the OperationOutcomes they are
A /api/fhir/* handler answers its own refusals as FHIR: a bad search parameter is a 400 OperationOutcome, the hourly fhir bucket a 429 OperationOutcome (issue code throttled), both served as application/fhir+json, the same way the 404 on a read already was. The contract declared no 400 and the generic JSON 429 for the 429, and no 403 for the doctorReport module gate, which every data route runs. The searches now declare the 400, every data route the 403 module.disabled envelope and the FHIR 429 with the rate-limit headers apiHandler attaches to it. The module gate keeps answering the JSON envelope: it is the errorCode every client already branches on for a disabled module, and it runs before the FHIR face does. The route tests drop their skips and assert the 429's content type and Retry-After.
This commit is contained in:
1 parent
c272324c1e
commit
5ca3994523
4 files changed
+591
-533
No files matched your search
+541
-517
File diff suppressed because it is too large.
Load diff
@@ -15,10 +15,44 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
moduleDisabledResponse,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
// The FHIR face answers its own refusals as FHIR: a 400, 404 or 429 raised
|
||||
// by a `/api/fhir/*` handler is an `OperationOutcome` served as
|
||||
// `application/fhir+json` (`operationOutcome` in `@/lib/fhir/rest`), which is
|
||||
// what a FHIR client parses on any non-2xx. The refusals that run before the
|
||||
// handler's own body are the API's: the generic 401 / 403 envelopes and the
|
||||
// 403 `module.disabled`, which keeps the errorCode every client already
|
||||
// branches on for a disabled module. Declared here after `...stdResponses`,
|
||||
// so the FHIR shapes replace the generic JSON ones.
|
||||
const fhirOutcomeContent = {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
};
|
||||
|
||||
const fhirDataRefusals = {
|
||||
...moduleDisabledResponse,
|
||||
"429": {
|
||||
description:
|
||||
"Rate limit exceeded: the per-account `fhir:<accountId>` bucket, 120 requests per hour, shared by every `/api/fhir/*` data route. The body is an `OperationOutcome` (`issue[0].code` = `throttled`) served as `application/fhir+json`, not the JSON error envelope. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe the bucket; back off on `Retry-After`.",
|
||||
content: fhirOutcomeContent,
|
||||
headers: stdResponses["429"].headers,
|
||||
},
|
||||
};
|
||||
|
||||
const fhirSearchRefusals = {
|
||||
...fhirDataRefusals,
|
||||
"400": {
|
||||
description:
|
||||
"A search parameter the server cannot honour, such as an `_offset` above 9007199254740991. The body is an `OperationOutcome` (`issue[0].code` = `invalid`, the reason in `diagnostics`) served as `application/fhir+json`.",
|
||||
content: fhirOutcomeContent,
|
||||
},
|
||||
};
|
||||
|
||||
// The two request bodies this module publishes. `.meta()` CLONES in Zod 4
|
||||
// rather than annotating in place, so the returned schema has to be captured
|
||||
// and referenced: a bare `schema.meta({...})` statement registers nothing and
|
||||
@@ -394,6 +428,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirSearchRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -426,6 +461,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirSearchRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -456,6 +492,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirSearchRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -486,6 +523,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirSearchRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -516,6 +554,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirSearchRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -546,6 +585,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirDataRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -576,6 +616,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirDataRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -606,6 +647,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirDataRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -636,6 +678,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirDataRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -666,6 +709,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirDataRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -696,6 +740,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...fhirSearchRefusals,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
* record leaves; the static CapabilityStatement carries no user data and
|
||||
* stays reachable. The module is switched the way the settings switch it,
|
||||
* through the stored per-account preferences.
|
||||
*
|
||||
* The registry declares no 403 for these operations; the refusals below skip
|
||||
* the contract check for that reason.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -45,9 +42,6 @@ import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const INSURANCE_NUMBER = "A123456789";
|
||||
const MODULE_OFF_SKIP =
|
||||
"the FHIR operations declare no 403; the module gate's module.disabled envelope is undocumented on them";
|
||||
|
||||
type Handler = Parameters<typeof callRoute>[0];
|
||||
|
||||
/** Every data route, by the path a caller hits; the reads name a held id. */
|
||||
@@ -135,7 +129,7 @@ async function seedOwner(doctorReport: boolean) {
|
||||
return user;
|
||||
}
|
||||
|
||||
async function callEach(auth: RouteAuth, skip: boolean) {
|
||||
async function callEach(auth: RouteAuth) {
|
||||
const results = [];
|
||||
for (const { handler, route, params } of DATA_ROUTES) {
|
||||
const res = await callRoute(handler, {
|
||||
@@ -143,7 +137,6 @@ async function callEach(auth: RouteAuth, skip: boolean) {
|
||||
route,
|
||||
params,
|
||||
auth,
|
||||
contract: skip ? { skip: MODULE_OFF_SKIP } : undefined,
|
||||
});
|
||||
results.push({ route, res });
|
||||
}
|
||||
@@ -158,7 +151,7 @@ describe("/api/fhir/* and the doctorReport module", () => {
|
||||
await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }),
|
||||
await makeSession(owner),
|
||||
]) {
|
||||
for (const { route, res } of await callEach(auth, true)) {
|
||||
for (const { route, res } of await callEach(auth)) {
|
||||
expect(res.status, route).toBe(403);
|
||||
expect(res.data, route).toBeNull();
|
||||
expect(res.meta, route).toMatchObject({
|
||||
@@ -177,7 +170,7 @@ describe("/api/fhir/* and the doctorReport module", () => {
|
||||
const owner = await seedOwner(true);
|
||||
const auth = await makeToken(owner, { permissions: [FHIR_READ_SCOPE] });
|
||||
|
||||
for (const { route, res } of await callEach(auth, false)) {
|
||||
for (const { route, res } of await callEach(auth)) {
|
||||
expect(res.status, route).toBe(200);
|
||||
expect(res.headers.get("content-type"), route).toContain(
|
||||
"application/fhir+json",
|
||||
|
||||
@@ -758,9 +758,6 @@ describe("FHIR paging", () => {
|
||||
route: "/api/fhir/Observation",
|
||||
query: { _offset: "1e20" },
|
||||
auth,
|
||||
contract: {
|
||||
skip: "the registry declares no 400 on the FHIR searches, though the description names the 400 OperationOutcome",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
@@ -784,12 +781,11 @@ describe("FHIR paging", () => {
|
||||
method: "GET",
|
||||
route: "/api/fhir/Patient",
|
||||
auth,
|
||||
contract: {
|
||||
skip: "the registry declares the shared 429 as an application/json error envelope; the FHIR routes answer an application/fhir+json OperationOutcome",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("content-type")).toContain("application/fhir+json");
|
||||
expect(Number(res.headers.get("retry-after"))).toBeGreaterThanOrEqual(1);
|
||||
expect(outcomeSchema.parse(res.body).issue[0].code).toBe("throttled");
|
||||
expect(JSON.stringify(res.body)).not.toContain("Sample Patient");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user