diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0adfaa593..62c9b564c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1274,9 +1274,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1312,9 +1312,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. - in: query name: date schema: @@ -1349,9 +1349,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1378,9 +1378,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1408,9 +1408,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1605,9 +1605,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index eaf538a41..5d2fd4499 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -39,7 +39,12 @@ vi.mock("next/headers", () => ({ 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 { + loadFhirContext, + MAX_COUNT, + MAX_OFFSET, + searchsetResponse, +} from "@/lib/fhir/rest"; import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate"; import { observationsFromReportData } from "@/lib/fhir/resources"; @@ -147,9 +152,9 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(bundle.entry[0].resource.id).toBe("obs-3"); }); - it("refuses an offset past the ceiling with a 400 OperationOutcome", async () => { + it("refuses only an offset a number cannot carry exactly", async () => { vi.mocked(observationsFromReportData).mockReturnValue([obs(1)] as never); - for (const offset of ["100001", "1e20", "Infinity"]) { + for (const offset of ["9007199254740992", "1e20", "Infinity"]) { const res = await observationGet(req(`?_offset=${offset}`)); expect(res.status).toBe(400); const outcome = (await res.json()) as { @@ -159,9 +164,35 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(outcome.resourceType).toBe("OperationOutcome"); expect(outcome.issue[0].code).toBe("invalid"); } - // The ceiling itself still pages (an empty page past the end). - const atCeiling = await observationGet(req("?_offset=100000")); - expect(atCeiling.status).toBe(200); + // A long series pages past a hundred thousand rows, and the ceiling + // itself still pages (an empty page past the end). + for (const offset of ["100001", "250000", String(MAX_OFFSET)]) { + const res = await observationGet(req(`?_offset=${offset}`)); + expect(res.status).toBe(200); + } + }); + + it("keeps emitting next links deep into a long series, never past the ceiling", () => { + const url = new URL("http://localhost/api/fhir/Observation"); + const deep = searchsetResponse(url, [], 300_000, 50, 150_000); + const nextOf = async (res: Response) => + ( + (await res.json()) as { link: Array<{ relation: string; url: string }> } + ).link.find((l) => l.relation === "next"); + return Promise.all([ + nextOf(deep).then((next) => { + expect(next?.url).toContain("_offset=150050"); + }), + nextOf( + searchsetResponse( + url, + [], + Number.POSITIVE_INFINITY, + 50, + MAX_OFFSET - 10, + ), + ).then((next) => expect(next).toBeUndefined()), + ]); }); it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => { diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index ebd1c54e3..053ffd736 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -92,19 +92,21 @@ export const DEFAULT_COUNT = 50; export const MAX_COUNT = 200; /** - * Ceiling on `_offset`. No record pages that far (the largest search, the - * Observation time series, is bounded by the readings in a date range), and - * an unbounded offset reached the database as a skip it cannot represent and - * answered 500. + * Ceiling on `_offset`: the largest integer a JavaScript number holds + * exactly. It is no paging limit (the Observation time series of a heart-rate + * history pages well past a hundred thousand rows, and every page's `next` + * link must keep working); it refuses only an offset that cannot be passed + * on faithfully. Above it `Math.floor` returns a float such as `1e20`, which + * the database cannot read as a `bigint` `OFFSET` and which answered 500. */ -export const MAX_OFFSET = 100_000; +export const MAX_OFFSET = Number.MAX_SAFE_INTEGER; /** * 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. - * An offset above {@link MAX_OFFSET} is the one refusal: the caller answers - * it with a 400 `invalid` OperationOutcome. + * An offset above {@link MAX_OFFSET} (including `Infinity`) is the one + * refusal: the caller answers it with a 400 `invalid` OperationOutcome. */ export function parsePaging( searchParams: URLSearchParams, @@ -127,6 +129,7 @@ export function parsePaging( * 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`. + * A next link never carries an offset {@link parsePaging} would refuse. */ function pagingLinks( requestUrl: URL, @@ -140,7 +143,7 @@ function pagingLinks( const links: FhirBundleLink[] = [{ relation: "self", url: self.toString() }]; const nextOffset = offset + count; - if (nextOffset < total) { + if (nextOffset < total && nextOffset <= MAX_OFFSET) { const next = new URL(requestUrl.toString()); next.searchParams.set("_count", String(count)); next.searchParams.set("_offset", String(nextOffset)); diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 20e0e4042..303055b89 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -380,7 +380,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -410,7 +410,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), date: z.string().optional(), code: z.string().optional(), @@ -442,7 +442,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -472,7 +472,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -502,7 +502,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -682,7 +682,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), },