Skip to content

Commit 48274f8

Browse files
committed
fix(client): stop BundleParser handing back types it never checked
FhirResourceReader.search() already returns Bundle<WithId<T>>, and every BundleParser method threw that away, so callers cast back to what they started with. Three defects, one template, so r4/r4b/r5 are fixed together. - getAllResources is generic in the bundle's element type, so a searchset comes back as what the reader knew instead of widening to Resource[]. - The resourceType argument is checked against the FHIR resourceType union: a typo, or a type argument that disagrees with it, is now a compile error instead of an empty array typed as the resource that was asked for. - Nothing promises WithId any more. id is optional in FHIR (contained resources and POST bundle entries have none) and was never verified, so the promise moved into the hasId guard, which callers chain where the guarantee is needed. - resolveReference takes its return type from the bundle rather than asserting one; resolveReferenceOfType narrows a mixed bundle by a checked resourceType and returns undefined when the reference resolves elsewhere. reader.ts loses the two casts that repeated the same pattern on values the bundle type already described. Breaking for consumers of the 0.2 line, so the three packages go to 0.3.0 and each README carries the migration. CI now builds and tests every package under packages/. They were compiled only at release time, so a type error in the template reached npm before anything ran tsc over it. Closes #149
1 parent ff74594 commit 48274f8

30 files changed

Lines changed: 913 additions & 171 deletions

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,34 @@ jobs:
4646
- name: Report newer IG releases
4747
run: npm run check:ig-versions
4848
continue-on-error: true
49+
50+
# The published packages under packages/ were only compiled at release time, so a
51+
# type error in scripts/client-codegen/template reached npm before anything ran
52+
# tsc over it. The drift check above proves the copies match the template; this
53+
# proves the template itself compiles and its tests pass.
54+
packages:
55+
runs-on: ubuntu-latest
56+
strategy:
57+
fail-fast: false
58+
matrix:
59+
package: [client-r4, client-r4b, client-r5, dicomweb, oauth-crypto, smart-auth, zod]
60+
steps:
61+
- uses: actions/checkout@v7
62+
63+
- uses: actions/setup-node@v7
64+
with:
65+
node-version: '22'
66+
cache: 'npm'
67+
68+
# The packages resolve @max-health-inc/config (their vitest preset) from the
69+
# repo root, so the root install has to happen before the package's own tests.
70+
- run: npm ci
71+
72+
- run: npm ci
73+
working-directory: packages/${{ matrix.package }}
74+
75+
- run: npm run build
76+
working-directory: packages/${{ matrix.package }}
77+
78+
- run: npm run test --if-present
79+
working-directory: packages/${{ matrix.package }}

packages/client-r4/README.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,45 @@ const patient = await client.patient().read('example');
7272
### Bundle Parsing
7373

7474
```ts
75-
import { BundleParser } from '@babelfhir-ts/client-r4';
75+
import { BundleParser, hasId } from '@babelfhir-ts/client-r4';
7676

77-
const patients = BundleParser.getResourcesByType<fhir4.Patient>(bundle, 'Patient');
78-
const first = BundleParser.getFirstResourceByType<fhir4.Patient>(bundle, 'Patient');
77+
// A searchset keeps the type the reader already knew.
78+
const searchset = await client.read().patient().search({ name: 'Smith' }); // Bundle<WithId<fhir4.Patient>>
79+
const all = BundleParser.getAllResources(searchset); // WithId<fhir4.Patient>[]
80+
81+
// On a mixed bundle the return type follows the resourceType argument — no type argument, no cast.
82+
const patients = BundleParser.getResourcesByType(bundle, 'Patient'); // fhir4.Patient[]
83+
const first = BundleParser.getFirstResourceByType(bundle, 'Observation'); // fhir4.Observation | undefined
84+
85+
// `id` is optional in FHIR, so narrow to it explicitly where it is required.
86+
const identified = patients.filter(hasId); // WithId<fhir4.Patient>[]
87+
88+
// References resolve to the bundle's element type, or to a checked resourceType.
89+
const subject = BundleParser.resolveReferenceOfType(obs.subject, bundle, 'Patient');
90+
```
91+
92+
Pass an explicit type argument for profile types from other generated packages —
93+
`resourceType` is then constrained to that type's own discriminant:
94+
95+
```ts
96+
BundleParser.getResourcesByType<USCorePatientProfile>(bundle, 'Patient');
7997
```
8098

99+
### Migrating to 0.3
100+
101+
`BundleParser` no longer returns types it has not checked, so code written against
102+
0.2 can see new compile errors:
103+
104+
- `getResourcesByType` and `getFirstResourceByType` return `T` instead of
105+
`WithId<T>`. `id` is optional in FHIR and was never verified, so add
106+
`.filter(hasId)` where the guarantee is needed.
107+
- `resourceType` is checked against the FHIR resource type union, which turns a
108+
typo into a compile error. A value chosen at runtime should be typed
109+
`FhirResourceType` rather than `string`.
110+
- `resolveReference` takes its return type from the bundle. To narrow a mixed
111+
bundle, call `resolveReferenceOfType(ref, bundle, 'Patient')`, which returns
112+
`undefined` when the reference resolves to another resource type.
113+
81114
## API
82115

83116
| Export | Description |
@@ -87,6 +120,8 @@ const first = BundleParser.getFirstResourceByType<fhir4.Patient>(bundle, 'Patien
87120
| `FhirClient` | Combined read + write client |
88121
| `SmartFhirClient` | SMART-authenticated FHIR client |
89122
| `BundleParser` | Static bundle extraction helpers |
123+
| `hasId` | Type guard narrowing a resource to `WithId<T>` |
124+
| `FhirResourceType` / `ResourceOfType` | The `resourceType` union, and the resource behind one of its members |
90125
| `SmartAuth` | SMART on FHIR v2 authorization (re-exported from `@babelfhir-ts/smart-auth`) |
91126
| `discoverEndpoints` | SMART endpoint discovery |
92127

packages/client-r4/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/client-r4/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@babelfhir-ts/client-r4",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "Typed FHIR R4 client with read/write/search methods for all 145 base resource types, SMART on FHIR auth, and bundle parsing",
55
"type": "module",
66
"main": "dist/index.js",

packages/client-r4/src/bundle-parser.test.ts

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33

44
import { describe, it, expect } from "vitest";
55
import { BundleParser } from "./bundle-parser.js";
6-
import type { Bundle } from "./types.js";
6+
import { hasId } from "./types.js";
7+
import type { Bundle, WithId } from "./types.js";
78

89
describe("BundleParser", () => {
910
const patient1 = { resourceType: "Patient" as const, id: "1", name: [{ family: "Smith" }] };
@@ -22,14 +23,14 @@ describe("BundleParser", () => {
2223

2324
describe("getResourcesByType", () => {
2425
it("filters resources by resourceType", () => {
25-
const patients = BundleParser.getResourcesByType<fhir4.Patient>(bundle, "Patient");
26+
const patients = BundleParser.getResourcesByType(bundle, "Patient");
2627
expect(patients).toHaveLength(2);
27-
expect(patients[0].id).toBe("1");
28-
expect(patients[1].id).toBe("2");
28+
expect(patients[0]?.id).toBe("1");
29+
expect(patients[1]?.id).toBe("2");
2930
});
3031

3132
it("returns empty array for non-matching type", () => {
32-
const conditions = BundleParser.getResourcesByType<fhir4.Condition>(bundle, "Condition");
33+
const conditions = BundleParser.getResourcesByType(bundle, "Condition");
3334
expect(conditions).toEqual([]);
3435
});
3536

@@ -46,11 +47,27 @@ describe("BundleParser", () => {
4647
};
4748
expect(BundleParser.getResourcesByType(noRes, "Patient")).toEqual([]);
4849
});
50+
51+
it("keeps resources that have no id, and hasId drops them", () => {
52+
const posting: Bundle<fhir4.Resource> = {
53+
resourceType: "Bundle",
54+
type: "transaction",
55+
entry: [
56+
{ resource: { resourceType: "Patient" } as fhir4.Resource, request: { method: "POST", url: "Patient" } },
57+
{ resource: patient1 as fhir4.Resource },
58+
],
59+
};
60+
expect(BundleParser.getResourcesByType(posting, "Patient")).toHaveLength(2);
61+
62+
const withIds: WithId<fhir4.Patient>[] = BundleParser.getResourcesByType(posting, "Patient").filter(hasId);
63+
expect(withIds).toHaveLength(1);
64+
expect(withIds[0]?.id).toBe("1");
65+
});
4966
});
5067

5168
describe("getFirstResourceByType", () => {
5269
it("returns first matching resource", () => {
53-
const first = BundleParser.getFirstResourceByType<fhir4.Patient>(bundle, "Patient");
70+
const first = BundleParser.getFirstResourceByType(bundle, "Patient");
5471
expect(first?.id).toBe("1");
5572
});
5673

@@ -65,6 +82,17 @@ describe("BundleParser", () => {
6582
expect(all).toHaveLength(3);
6683
});
6784

85+
it("preserves the bundle's element type", () => {
86+
const searchset: Bundle<WithId<fhir4.Patient>> = {
87+
resourceType: "Bundle",
88+
type: "searchset",
89+
entry: [{ resource: patient1 }, { resource: patient2 }],
90+
};
91+
// No cast: what the reader knew is what comes back.
92+
const patients: WithId<fhir4.Patient>[] = BundleParser.getAllResources(searchset);
93+
expect(patients.map(p => p.id)).toEqual(["1", "2"]);
94+
});
95+
6896
it("returns empty array for empty bundle", () => {
6997
const empty: Bundle<fhir4.Resource> = { resourceType: "Bundle", type: "searchset" };
7098
expect(BundleParser.getAllResources(empty)).toEqual([]);
@@ -96,26 +124,23 @@ describe("BundleParser", () => {
96124
};
97125

98126
it("resolves by exact fullUrl match", () => {
99-
const result = BundleParser.resolveReference<fhir4.Patient>(
127+
const result = BundleParser.resolveReference(
100128
{ reference: "https://fhir.example.com/Patient/1" },
101129
bundleWithFullUrls,
102130
);
103131
expect(result?.id).toBe("1");
104132
});
105133

106134
it("resolves by urn:uuid fullUrl match", () => {
107-
const result = BundleParser.resolveReference<fhir4.Patient>(
135+
const result = BundleParser.resolveReference(
108136
{ reference: "urn:uuid:abc-123" },
109137
bundleWithFullUrls,
110138
);
111139
expect(result?.id).toBe("2");
112140
});
113141

114142
it("resolves by ResourceType/id relative reference", () => {
115-
const result = BundleParser.resolveReference<fhir4.Patient>(
116-
{ reference: "Patient/1" },
117-
bundleWithFullUrls,
118-
);
143+
const result = BundleParser.resolveReference({ reference: "Patient/1" }, bundleWithFullUrls);
119144
expect(result?.id).toBe("1");
120145
});
121146

@@ -127,10 +152,7 @@ describe("BundleParser", () => {
127152
{ fullUrl: "https://fhir.example.com/Patient/99", resource: { resourceType: "Patient" } as fhir4.Resource },
128153
],
129154
};
130-
const result = BundleParser.resolveReference<fhir4.Patient>(
131-
{ reference: "Patient/99" },
132-
bundleNoId,
133-
);
155+
const result = BundleParser.resolveReference({ reference: "Patient/99" }, bundleNoId);
134156
expect(result?.resourceType).toBe("Patient");
135157
});
136158

@@ -151,5 +173,47 @@ describe("BundleParser", () => {
151173
bundleWithFullUrls,
152174
)).toBeUndefined();
153175
});
176+
177+
it("preserves the bundle's element type", () => {
178+
const searchset: Bundle<WithId<fhir4.Patient>> = {
179+
resourceType: "Bundle",
180+
type: "searchset",
181+
entry: [{ fullUrl: "https://fhir.example.com/Patient/1", resource: patient1 }],
182+
};
183+
const resolved: WithId<fhir4.Patient> | undefined = BundleParser.resolveReference(
184+
{ reference: "Patient/1" },
185+
searchset,
186+
);
187+
expect(resolved?.id).toBe("1");
188+
});
189+
});
190+
191+
describe("resolveReferenceOfType", () => {
192+
const bundleWithFullUrls: Bundle<fhir4.Resource> = {
193+
resourceType: "Bundle",
194+
type: "searchset",
195+
entry: [
196+
{ fullUrl: "https://fhir.example.com/Patient/1", resource: patient1 as fhir4.Resource },
197+
{ fullUrl: "https://fhir.example.com/Observation/obs-1", resource: observation as fhir4.Resource },
198+
],
199+
};
200+
201+
it("resolves a reference of the requested type", () => {
202+
const result = BundleParser.resolveReferenceOfType(
203+
{ reference: "Patient/1" },
204+
bundleWithFullUrls,
205+
"Patient",
206+
);
207+
expect(result?.id).toBe("1");
208+
});
209+
210+
it("returns undefined when the reference resolves to another type", () => {
211+
const result = BundleParser.resolveReferenceOfType(
212+
{ reference: "https://fhir.example.com/Observation/obs-1" },
213+
bundleWithFullUrls,
214+
"Patient",
215+
);
216+
expect(result).toBeUndefined();
217+
});
154218
});
155219
});

0 commit comments

Comments
 (0)