babelfhir-ts 1.0.35 → 1.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -14
- package/out/src/generator/clientGenerator.js +752 -18
- package/out/src/generator/index.js +29 -17
- package/out/src/main.js +38 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,9 +23,30 @@
|
|
|
23
23
|
- **Install any FHIR profile as a node module**—use `babelfhir-ts install` to add Implementation Guides directly to your project
|
|
24
24
|
|
|
25
25
|
<!-- PARITY-BADGES:START - Do not remove or modify this section -->
|
|
26
|
-
##
|
|
26
|
+
## Continuous Validation
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Every pull request runs two independent CI pipelines that validate generated code against real-world FHIR Implementation Guides. For each IG, the pipeline:
|
|
29
|
+
|
|
30
|
+
1. Downloads the FHIR package from a registry
|
|
31
|
+
2. Generates TypeScript interfaces, validators, and classes
|
|
32
|
+
3. Compiles the output with `tsc` (zero errors required)
|
|
33
|
+
4. Generates `empty()` and `random()` test resources for every profile
|
|
34
|
+
5. Validates those resources against two external FHIR validators
|
|
35
|
+
|
|
36
|
+
### Tested Implementation Guides
|
|
37
|
+
|
|
38
|
+
| Implementation Guide | Package | Profiles |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| US Core | `hl7.fhir.us.core@8.0.0` | Patient, Condition, Observation, Encounter, … |
|
|
41
|
+
| ISiK Basis (Germany) | `de.gematik.isik-basismodul@4.0.3` | ISiKPatient, ISiKDiagnose, … |
|
|
42
|
+
| IPS (International Patient Summary) | `hl7.fhir.uv.ips@2.0.0` | Composition, MedicationStatement, … |
|
|
43
|
+
| SMART App Launch | `hl7.fhir.uv.smart-app-launch@2.2.0` | WellKnown endpoints |
|
|
44
|
+
| CH Core (Switzerland) | `ch.fhir.ig.ch-core@5.0.0` | CHCorePatient, CHCoreEncounter, … |
|
|
45
|
+
| DaVinci PAS | `hl7.fhir.us.davinci-pas@2.0.1` | PASClaim, PASClaimResponse, … |
|
|
46
|
+
|
|
47
|
+
### Validation with Firely .NET SDK
|
|
48
|
+
|
|
49
|
+
The first pipeline validates generated resources using the [Firely .NET SDK validator](https://docs.fire.ly/projects/Firely-NET-SDK/) (v3.0.1). Results are published as live badges:
|
|
29
50
|
|
|
30
51
|

|
|
31
52
|

|
|
@@ -33,14 +54,11 @@ BabelFHIR-TS validators achieve **100% parity** with the Firely .NET SDK validat
|
|
|
33
54
|

|
|
34
55
|

|
|
35
56
|
|
|
36
|
-
>
|
|
37
|
-
|
|
38
|
-
📊 **[Full Report](https://gist.github.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f)** · *Last updated: Dec 10, 2025*
|
|
57
|
+
> 11 profiles are excluded from this pipeline due to schema loading issues in the Firely SDK. These profiles validate successfully with the HL7 Java Validator below. Details in [docs/FIRELY-VALIDATOR-BUGS.md](./docs/FIRELY-VALIDATOR-BUGS.md).
|
|
39
58
|
|
|
40
|
-
|
|
41
|
-
### HL7 Java Validator Parity
|
|
59
|
+
### Validation with HL7 Java Validator
|
|
42
60
|
|
|
43
|
-
|
|
61
|
+
The second pipeline validates using the [official HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator) (v6.3.11), the reference implementation for FHIR conformance checking:
|
|
44
62
|
|
|
45
63
|

|
|
46
64
|

|
|
@@ -48,9 +66,9 @@ Generated `random()` and `empty()` methods also pass structural validation with
|
|
|
48
66
|

|
|
49
67
|

|
|
50
68
|
|
|
51
|
-
>
|
|
69
|
+
> Terminology validation requires a tx server. The pipeline uses `--tx-server https://tx.fhir.org/r4` during generation to expand ValueSets and produce valid codes.
|
|
52
70
|
|
|
53
|
-
📊 **[Full Report](https://gist.github.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f)**
|
|
71
|
+
📊 **[Full Report & Badge Data](https://gist.github.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f)**
|
|
54
72
|
<!-- HL7-PARITY-BADGES:END -->
|
|
55
73
|
<!-- PARITY-BADGES:END -->
|
|
56
74
|
|
|
@@ -233,7 +251,7 @@ For comprehensive conformance testing, use the official [HL7 FHIR Validator](htt
|
|
|
233
251
|
- Reference integrity constraints
|
|
234
252
|
|
|
235
253
|
Use `random()` for development, testing, and prototyping, but always validate production data on the FHIR server side.
|
|
236
|
-
- **`validate()`
|
|
254
|
+
- **`validate()` coverage**: The generated validation methods execute FHIRPath expressions and check constraints from StructureDefinitions. They are tested against the HL7 Java Validator and the Firely .NET SDK for the Implementation Guides listed in the [Continuous Validation](#continuous-validation) section. Edge cases, complex slicing patterns, or profiles not in the CI pipeline may produce different results. For production conformance testing, use the official [HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator) as the source of truth.
|
|
237
255
|
|
|
238
256
|
### FHIR Version Support
|
|
239
257
|
|
|
@@ -242,7 +260,7 @@ For comprehensive conformance testing, use the official [HL7 FHIR Validator](htt
|
|
|
242
260
|
|
|
243
261
|
### Reporting Issues
|
|
244
262
|
|
|
245
|
-
If you encounter an Implementation Guide that doesn't generate correctly, please [open an issue](https://github.com/
|
|
263
|
+
If you encounter an Implementation Guide that doesn't generate correctly, please [open an issue](https://github.com/Max-Health-Inc/BabelFHIR-TS/issues) with:
|
|
246
264
|
|
|
247
265
|
- The package name and version
|
|
248
266
|
- The specific StructureDefinition URL
|
|
@@ -266,6 +284,6 @@ For security issues, please see [SECURITY.md](SECURITY.md) for our security poli
|
|
|
266
284
|
## Links
|
|
267
285
|
|
|
268
286
|
- [npm package](https://www.npmjs.com/package/babelfhir-ts)
|
|
269
|
-
- [GitHub repository](https://github.com/
|
|
270
|
-
- [Issue tracker](https://github.com/
|
|
287
|
+
- [GitHub repository](https://github.com/Max-Health-Inc/BabelFHIR-TS)
|
|
288
|
+
- [Issue tracker](https://github.com/Max-Health-Inc/BabelFHIR-TS/issues)
|
|
271
289
|
- [Changelog](CHANGELOG.md)
|
|
@@ -28,9 +28,11 @@ export function generateClient(options) {
|
|
|
28
28
|
generateResourceWriter(clientDir, resourceTypes);
|
|
29
29
|
generateBundleParser(clientDir, resourceTypes);
|
|
30
30
|
generateFhirClient(clientDir, resourceTypes);
|
|
31
|
+
generateSmartAuth(clientDir);
|
|
32
|
+
generateSmartClient(clientDir, resourceTypes);
|
|
31
33
|
generateIndex(clientDir, resourceTypes);
|
|
32
34
|
generateReadme(clientDir);
|
|
33
|
-
log.success(`Generated FHIR client with ${resourceTypes.length} resource types`);
|
|
35
|
+
log.success(`Generated FHIR client with ${resourceTypes.length} resource types + SMART auth`);
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
36
38
|
* Generate types.ts
|
|
@@ -120,18 +122,28 @@ export interface FhirResourceSearcher<T> {
|
|
|
120
122
|
*/
|
|
121
123
|
${readerTypes}
|
|
122
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Custom fetch function type for injecting auth headers
|
|
127
|
+
*/
|
|
128
|
+
export type FetchFn = typeof globalThis.fetch;
|
|
129
|
+
|
|
123
130
|
/**
|
|
124
131
|
* Implementation of FHIR resource reader
|
|
125
132
|
*/
|
|
126
133
|
export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
|
|
134
|
+
private readonly fetchFn: FetchFn;
|
|
135
|
+
|
|
127
136
|
constructor(
|
|
128
137
|
public readonly baseUrl: string,
|
|
129
138
|
public readonly resourceType: string,
|
|
130
|
-
|
|
139
|
+
fetchFn?: FetchFn,
|
|
140
|
+
) {
|
|
141
|
+
this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
142
|
+
}
|
|
131
143
|
|
|
132
144
|
async read(id: string): Promise<WithId<T>> {
|
|
133
145
|
const url = \`\${this.baseUrl}/\${this.resourceType}/\${id}\`;
|
|
134
|
-
const response = await
|
|
146
|
+
const response = await this.fetchFn(url, {
|
|
135
147
|
headers: {
|
|
136
148
|
Accept: "application/fhir+json",
|
|
137
149
|
},
|
|
@@ -163,7 +175,7 @@ export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
|
|
|
163
175
|
}
|
|
164
176
|
}
|
|
165
177
|
|
|
166
|
-
const response = await
|
|
178
|
+
const response = await this.fetchFn(url.toString(), {
|
|
167
179
|
headers: {
|
|
168
180
|
Accept: "application/fhir+json",
|
|
169
181
|
},
|
|
@@ -197,7 +209,7 @@ export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
|
|
|
197
209
|
|
|
198
210
|
if (!nextLink) break;
|
|
199
211
|
|
|
200
|
-
const response = await
|
|
212
|
+
const response = await this.fetchFn(nextLink, {
|
|
201
213
|
headers: {
|
|
202
214
|
Accept: "application/fhir+json",
|
|
203
215
|
},
|
|
@@ -258,18 +270,25 @@ export interface FhirResourceWriter<T> {
|
|
|
258
270
|
*/
|
|
259
271
|
${writerTypes}
|
|
260
272
|
|
|
273
|
+
import type { FetchFn } from "./resource-reader.js";
|
|
274
|
+
|
|
261
275
|
/**
|
|
262
276
|
* Implementation of FHIR resource writer
|
|
263
277
|
*/
|
|
264
278
|
export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
|
|
279
|
+
private readonly fetchFn: FetchFn;
|
|
280
|
+
|
|
265
281
|
constructor(
|
|
266
282
|
public readonly baseUrl: string,
|
|
267
283
|
public readonly resourceType: string,
|
|
268
|
-
|
|
284
|
+
fetchFn?: FetchFn,
|
|
285
|
+
) {
|
|
286
|
+
this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
287
|
+
}
|
|
269
288
|
|
|
270
289
|
async create(resource: T): Promise<WithId<T>> {
|
|
271
290
|
const url = \`\${this.baseUrl}/\${this.resourceType}\`;
|
|
272
|
-
const response = await
|
|
291
|
+
const response = await this.fetchFn(url, {
|
|
273
292
|
method: "POST",
|
|
274
293
|
headers: {
|
|
275
294
|
"Content-Type": "application/fhir+json",
|
|
@@ -289,7 +308,7 @@ export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
|
|
|
289
308
|
|
|
290
309
|
async update(resource: WithId<T>): Promise<WithId<T>> {
|
|
291
310
|
const url = \`\${this.baseUrl}/\${this.resourceType}/\${resource.id}\`;
|
|
292
|
-
const response = await
|
|
311
|
+
const response = await this.fetchFn(url, {
|
|
293
312
|
method: "PUT",
|
|
294
313
|
headers: {
|
|
295
314
|
"Content-Type": "application/fhir+json",
|
|
@@ -309,7 +328,7 @@ export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
|
|
|
309
328
|
|
|
310
329
|
async delete(id: string): Promise<void> {
|
|
311
330
|
const url = \`\${this.baseUrl}/\${this.resourceType}/\${id}\`;
|
|
312
|
-
const response = await
|
|
331
|
+
const response = await this.fetchFn(url, {
|
|
313
332
|
method: "DELETE",
|
|
314
333
|
});
|
|
315
334
|
|
|
@@ -516,7 +535,7 @@ function generateFhirClient(clientDir, resourceTypes) {
|
|
|
516
535
|
.map((rt) => {
|
|
517
536
|
const methodName = rt.profileName.charAt(0).toLowerCase() + rt.profileName.slice(1);
|
|
518
537
|
return ` ${methodName}(): ${rt.profileName}Reader {
|
|
519
|
-
return new FhirResourceReader(this.baseUrl, "${rt.baseResourceType}");
|
|
538
|
+
return new FhirResourceReader(this.baseUrl, "${rt.baseResourceType}", this.fetchFn);
|
|
520
539
|
}`;
|
|
521
540
|
})
|
|
522
541
|
.join("\n\n");
|
|
@@ -524,13 +543,14 @@ function generateFhirClient(clientDir, resourceTypes) {
|
|
|
524
543
|
.map((rt) => {
|
|
525
544
|
const methodName = rt.profileName.charAt(0).toLowerCase() + rt.profileName.slice(1);
|
|
526
545
|
return ` ${methodName}(): ${rt.profileName}Writer {
|
|
527
|
-
return new FhirResourceWriterImpl(this.baseUrl, "${rt.baseResourceType}");
|
|
546
|
+
return new FhirResourceWriterImpl(this.baseUrl, "${rt.baseResourceType}", this.fetchFn);
|
|
528
547
|
}`;
|
|
529
548
|
})
|
|
530
549
|
.join("\n\n");
|
|
531
550
|
const content = `import {
|
|
532
551
|
${readerImports}
|
|
533
552
|
FhirResourceReader,
|
|
553
|
+
type FetchFn,
|
|
534
554
|
} from "./resource-reader.js";
|
|
535
555
|
import {
|
|
536
556
|
${writerImports}
|
|
@@ -541,7 +561,10 @@ ${writerImports}
|
|
|
541
561
|
* FHIR Client for reading resources
|
|
542
562
|
*/
|
|
543
563
|
export class FhirReadClient {
|
|
544
|
-
constructor(
|
|
564
|
+
constructor(
|
|
565
|
+
private readonly baseUrl: string,
|
|
566
|
+
private readonly fetchFn?: FetchFn,
|
|
567
|
+
) {}
|
|
545
568
|
|
|
546
569
|
${readerMethods}
|
|
547
570
|
}
|
|
@@ -550,21 +573,34 @@ ${readerMethods}
|
|
|
550
573
|
* FHIR Client for writing resources
|
|
551
574
|
*/
|
|
552
575
|
export class FhirWriteClient {
|
|
553
|
-
constructor(
|
|
576
|
+
constructor(
|
|
577
|
+
private readonly baseUrl: string,
|
|
578
|
+
private readonly fetchFn?: FetchFn,
|
|
579
|
+
) {}
|
|
554
580
|
|
|
555
581
|
${writerMethods}
|
|
556
582
|
}
|
|
557
583
|
|
|
558
584
|
/**
|
|
559
|
-
* Main FHIR Client
|
|
585
|
+
* Main FHIR Client — works with plain fetch or an authenticated fetch wrapper.
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* // Unauthenticated
|
|
589
|
+
* const client = new FhirClient("https://fhir.example.com");
|
|
590
|
+
*
|
|
591
|
+
* // With custom fetch (e.g. from SmartFhirClient)
|
|
592
|
+
* const client = new FhirClient("https://fhir.example.com", authenticatedFetch);
|
|
560
593
|
*/
|
|
561
594
|
export class FhirClient {
|
|
562
595
|
private readonly readClient: FhirReadClient;
|
|
563
596
|
private readonly writeClient: FhirWriteClient;
|
|
564
597
|
|
|
565
|
-
constructor(
|
|
566
|
-
|
|
567
|
-
|
|
598
|
+
constructor(
|
|
599
|
+
public readonly baseUrl: string,
|
|
600
|
+
fetchFn?: FetchFn,
|
|
601
|
+
) {
|
|
602
|
+
this.readClient = new FhirReadClient(baseUrl, fetchFn);
|
|
603
|
+
this.writeClient = new FhirWriteClient(baseUrl, fetchFn);
|
|
568
604
|
}
|
|
569
605
|
|
|
570
606
|
/**
|
|
@@ -584,6 +620,604 @@ export class FhirClient {
|
|
|
584
620
|
`;
|
|
585
621
|
fs.writeFileSync(path.join(clientDir, "fhir-client.ts"), content);
|
|
586
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* Generate smart-auth.ts — PKCE, discovery, token management (static, IG-agnostic)
|
|
625
|
+
*/
|
|
626
|
+
function generateSmartAuth(clientDir) {
|
|
627
|
+
const content = `/**
|
|
628
|
+
* SMART on FHIR Authorization Module
|
|
629
|
+
*
|
|
630
|
+
* Implements SMART App Launch Framework (v2) with PKCE.
|
|
631
|
+
* Works in any browser environment — no framework dependencies.
|
|
632
|
+
*
|
|
633
|
+
* @see https://hl7.org/fhir/smart-app-launch/
|
|
634
|
+
*/
|
|
635
|
+
|
|
636
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
export interface SmartConfig {
|
|
639
|
+
/** OAuth2 client_id registered with the auth server */
|
|
640
|
+
clientId: string;
|
|
641
|
+
/** Redirect URI registered with the auth server */
|
|
642
|
+
redirectUri: string;
|
|
643
|
+
/** FHIR server base URL (used as \`aud\` param) */
|
|
644
|
+
fhirBaseUrl: string;
|
|
645
|
+
/** Space-separated OAuth2 scopes */
|
|
646
|
+
scopes: string;
|
|
647
|
+
/** Optional storage backend (defaults to sessionStorage) */
|
|
648
|
+
storage?: Storage;
|
|
649
|
+
/** Optional prefix for storage keys (defaults to "smart_") */
|
|
650
|
+
storagePrefix?: string;
|
|
651
|
+
/** Where to redirect after IdP logout (defaults to current origin) */
|
|
652
|
+
postLogoutRedirectUri?: string;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface SmartToken {
|
|
656
|
+
access_token: string;
|
|
657
|
+
token_type: string;
|
|
658
|
+
expires_in: number;
|
|
659
|
+
scope: string;
|
|
660
|
+
id_token?: string;
|
|
661
|
+
refresh_token?: string;
|
|
662
|
+
/** Extracted from id_token or token response */
|
|
663
|
+
fhirUser?: string;
|
|
664
|
+
/** Patient context from EHR launch */
|
|
665
|
+
patient?: string;
|
|
666
|
+
/** Encounter context from EHR launch */
|
|
667
|
+
encounter?: string;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface SmartConfiguration {
|
|
671
|
+
authorization_endpoint: string;
|
|
672
|
+
token_endpoint: string;
|
|
673
|
+
userinfo_endpoint?: string;
|
|
674
|
+
introspection_endpoint?: string;
|
|
675
|
+
revocation_endpoint?: string;
|
|
676
|
+
end_session_endpoint?: string;
|
|
677
|
+
capabilities?: string[];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export type LaunchMode = "standalone" | "ehr";
|
|
681
|
+
|
|
682
|
+
// ── PKCE helpers ─────────────────────────────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
function generateRandomString(length: number): string {
|
|
685
|
+
const array = new Uint8Array(length);
|
|
686
|
+
crypto.getRandomValues(array);
|
|
687
|
+
return Array.from(array, (b) => b.toString(36).padStart(2, "0"))
|
|
688
|
+
.join("")
|
|
689
|
+
.slice(0, length);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function sha256(plain: string): Promise<ArrayBuffer> {
|
|
693
|
+
const encoder = new TextEncoder();
|
|
694
|
+
return crypto.subtle.digest("SHA-256", encoder.encode(plain));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function base64UrlEncode(buffer: ArrayBuffer): string {
|
|
698
|
+
const bytes = new Uint8Array(buffer);
|
|
699
|
+
let binary = "";
|
|
700
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
701
|
+
return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Storage keys ─────────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
function storageKeys(prefix: string) {
|
|
707
|
+
return {
|
|
708
|
+
token: \`\${prefix}token\`,
|
|
709
|
+
verifier: \`\${prefix}code_verifier\`,
|
|
710
|
+
state: \`\${prefix}state\`,
|
|
711
|
+
expiresAt: \`\${prefix}expires_at\`,
|
|
712
|
+
launchMode: \`\${prefix}launch_mode\`,
|
|
713
|
+
} as const;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── Discovery ────────────────────────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Discover SMART authorization endpoints from a FHIR server.
|
|
720
|
+
* Tries \`.well-known/smart-configuration\` first, falls back to CapabilityStatement.
|
|
721
|
+
*/
|
|
722
|
+
export async function discoverEndpoints(fhirBaseUrl: string): Promise<SmartConfiguration> {
|
|
723
|
+
const smartUrl = \`\${fhirBaseUrl}/.well-known/smart-configuration\`;
|
|
724
|
+
try {
|
|
725
|
+
const res = await fetch(smartUrl);
|
|
726
|
+
if (res.ok) return (await res.json()) as SmartConfiguration;
|
|
727
|
+
} catch {
|
|
728
|
+
// fallthrough to CapabilityStatement
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const metaUrl = \`\${fhirBaseUrl}/metadata\`;
|
|
732
|
+
const res = await fetch(metaUrl, {
|
|
733
|
+
headers: { Accept: "application/fhir+json" },
|
|
734
|
+
});
|
|
735
|
+
if (!res.ok) {
|
|
736
|
+
throw new Error(\`Failed to discover SMART endpoints from \${metaUrl}\`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const meta = await res.json();
|
|
740
|
+
const security = meta.rest?.[0]?.security;
|
|
741
|
+
const oauthExt = security?.extension?.find(
|
|
742
|
+
(e: { url: string }) =>
|
|
743
|
+
e.url === "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris",
|
|
744
|
+
);
|
|
745
|
+
const findExt = (url: string): string | undefined =>
|
|
746
|
+
oauthExt?.extension?.find((e: { url: string }) => e.url === url)?.valueUri;
|
|
747
|
+
|
|
748
|
+
const authorization_endpoint = findExt("authorize");
|
|
749
|
+
const token_endpoint = findExt("token");
|
|
750
|
+
if (!authorization_endpoint || !token_endpoint) {
|
|
751
|
+
throw new Error(
|
|
752
|
+
"Could not find SMART authorization/token endpoints in CapabilityStatement",
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
return { authorization_endpoint, token_endpoint };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── SmartAuth class ──────────────────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Manages the SMART on FHIR authorization lifecycle.
|
|
762
|
+
*
|
|
763
|
+
* @example
|
|
764
|
+
* const auth = new SmartAuth({
|
|
765
|
+
* clientId: "my-app",
|
|
766
|
+
* redirectUri: "http://localhost:3000/callback",
|
|
767
|
+
* fhirBaseUrl: "https://fhir.example.com/fhir",
|
|
768
|
+
* scopes: "openid fhirUser patient/*.read",
|
|
769
|
+
* });
|
|
770
|
+
*
|
|
771
|
+
* // Check if this is a callback
|
|
772
|
+
* if (auth.isCallback()) {
|
|
773
|
+
* const token = await auth.handleCallback();
|
|
774
|
+
* } else {
|
|
775
|
+
* await auth.authorize(); // redirects to auth server
|
|
776
|
+
* }
|
|
777
|
+
*
|
|
778
|
+
* // Create authenticated fetch
|
|
779
|
+
* const authFetch = auth.createAuthenticatedFetch();
|
|
780
|
+
*/
|
|
781
|
+
export class SmartAuth {
|
|
782
|
+
private readonly storage: Storage;
|
|
783
|
+
private readonly keys: ReturnType<typeof storageKeys>;
|
|
784
|
+
|
|
785
|
+
constructor(public readonly config: SmartConfig) {
|
|
786
|
+
this.storage = config.storage ?? sessionStorage;
|
|
787
|
+
this.keys = storageKeys(config.storagePrefix ?? "smart_");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ── Launch ───────────────────────────────────────────────────────────
|
|
791
|
+
|
|
792
|
+
/** Detect launch mode from current URL parameters */
|
|
793
|
+
detectLaunchMode(): LaunchMode {
|
|
794
|
+
const params = new URLSearchParams(window.location.search);
|
|
795
|
+
return params.has("launch") && params.has("iss") ? "ehr" : "standalone";
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Start standalone SMART launch (redirects the browser) */
|
|
799
|
+
async authorize(): Promise<void> {
|
|
800
|
+
const mode = this.detectLaunchMode();
|
|
801
|
+
if (mode === "ehr") {
|
|
802
|
+
const params = new URLSearchParams(window.location.search);
|
|
803
|
+
await this.startEhrLaunch(
|
|
804
|
+
params.get("launch")!,
|
|
805
|
+
params.get("iss")!,
|
|
806
|
+
);
|
|
807
|
+
} else {
|
|
808
|
+
await this.startStandaloneLaunch();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Start standalone SMART launch */
|
|
813
|
+
async startStandaloneLaunch(): Promise<void> {
|
|
814
|
+
const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
|
|
815
|
+
const { codeChallenge, codeVerifier, state } = await this.preparePkce();
|
|
816
|
+
|
|
817
|
+
this.storage.setItem(this.keys.launchMode, "standalone");
|
|
818
|
+
|
|
819
|
+
const params = new URLSearchParams({
|
|
820
|
+
response_type: "code",
|
|
821
|
+
client_id: this.config.clientId,
|
|
822
|
+
redirect_uri: this.config.redirectUri,
|
|
823
|
+
scope: this.config.scopes,
|
|
824
|
+
state,
|
|
825
|
+
code_challenge: codeChallenge,
|
|
826
|
+
code_challenge_method: "S256",
|
|
827
|
+
aud: this.config.fhirBaseUrl,
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
window.location.href = \`\${endpoints.authorization_endpoint}?\${params}\`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Start EHR SMART launch */
|
|
834
|
+
async startEhrLaunch(launch: string, iss: string): Promise<void> {
|
|
835
|
+
const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
|
|
836
|
+
const { codeChallenge, codeVerifier, state } = await this.preparePkce();
|
|
837
|
+
|
|
838
|
+
this.storage.setItem(this.keys.launchMode, "ehr");
|
|
839
|
+
|
|
840
|
+
const params = new URLSearchParams({
|
|
841
|
+
response_type: "code",
|
|
842
|
+
client_id: this.config.clientId,
|
|
843
|
+
redirect_uri: this.config.redirectUri,
|
|
844
|
+
scope: this.config.scopes,
|
|
845
|
+
state,
|
|
846
|
+
code_challenge: codeChallenge,
|
|
847
|
+
code_challenge_method: "S256",
|
|
848
|
+
aud: iss,
|
|
849
|
+
launch,
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
window.location.href = \`\${endpoints.authorization_endpoint}?\${params}\`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ── Callback ─────────────────────────────────────────────────────────
|
|
856
|
+
|
|
857
|
+
/** Check if the current URL is an OAuth callback */
|
|
858
|
+
isCallback(): boolean {
|
|
859
|
+
const params = new URLSearchParams(window.location.search);
|
|
860
|
+
return params.has("code") || params.has("error");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Handle the OAuth callback — exchanges code for token */
|
|
864
|
+
async handleCallback(): Promise<SmartToken> {
|
|
865
|
+
const params = new URLSearchParams(window.location.search);
|
|
866
|
+
const code = params.get("code");
|
|
867
|
+
const state = params.get("state");
|
|
868
|
+
const error = params.get("error");
|
|
869
|
+
|
|
870
|
+
if (error) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
\`Authorization error: \${error} — \${params.get("error_description") ?? ""}\`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (!code) {
|
|
876
|
+
throw new Error("Missing authorization code in callback");
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const savedState = this.storage.getItem(this.keys.state);
|
|
880
|
+
if (state !== savedState) {
|
|
881
|
+
throw new Error("State mismatch — possible CSRF");
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const codeVerifier = this.storage.getItem(this.keys.verifier);
|
|
885
|
+
if (!codeVerifier) {
|
|
886
|
+
throw new Error("Missing PKCE code_verifier");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
|
|
890
|
+
|
|
891
|
+
const body = new URLSearchParams({
|
|
892
|
+
grant_type: "authorization_code",
|
|
893
|
+
code,
|
|
894
|
+
redirect_uri: this.config.redirectUri,
|
|
895
|
+
client_id: this.config.clientId,
|
|
896
|
+
code_verifier: codeVerifier,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
const res = await fetch(endpoints.token_endpoint, {
|
|
900
|
+
method: "POST",
|
|
901
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
902
|
+
body,
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
if (!res.ok) {
|
|
906
|
+
const text = await res.text();
|
|
907
|
+
throw new Error(\`Token exchange failed (\${res.status}): \${text}\`);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const token: SmartToken = await res.json();
|
|
911
|
+
|
|
912
|
+
// Extract fhirUser from id_token claims
|
|
913
|
+
if (token.id_token) {
|
|
914
|
+
try {
|
|
915
|
+
const payload = JSON.parse(atob(token.id_token.split(".")[1]));
|
|
916
|
+
token.fhirUser = payload.fhirUser ?? payload.profile;
|
|
917
|
+
} catch {
|
|
918
|
+
// id_token decode failed — non-critical
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Clean up PKCE state
|
|
923
|
+
this.storage.removeItem(this.keys.verifier);
|
|
924
|
+
this.storage.removeItem(this.keys.state);
|
|
925
|
+
|
|
926
|
+
// Save token + expiry
|
|
927
|
+
this.storage.setItem(this.keys.token, JSON.stringify(token));
|
|
928
|
+
if (token.expires_in) {
|
|
929
|
+
const expiresAt = Date.now() + (token.expires_in - 60) * 1000;
|
|
930
|
+
this.storage.setItem(this.keys.expiresAt, String(expiresAt));
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return token;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ── Token management ─────────────────────────────────────────────────
|
|
937
|
+
|
|
938
|
+
/** Get the stored token, or null if not authenticated */
|
|
939
|
+
getToken(): SmartToken | null {
|
|
940
|
+
const raw = this.storage.getItem(this.keys.token);
|
|
941
|
+
if (!raw) return null;
|
|
942
|
+
try {
|
|
943
|
+
return JSON.parse(raw) as SmartToken;
|
|
944
|
+
} catch {
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Get the stored launch mode */
|
|
950
|
+
getLaunchMode(): LaunchMode {
|
|
951
|
+
return (this.storage.getItem(this.keys.launchMode) as LaunchMode) ?? "standalone";
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Check if the user is authenticated */
|
|
955
|
+
isAuthenticated(): boolean {
|
|
956
|
+
return this.getToken() !== null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Check if the current token is expired */
|
|
960
|
+
isTokenExpired(): boolean {
|
|
961
|
+
const expiresAt = this.storage.getItem(this.keys.expiresAt);
|
|
962
|
+
if (!expiresAt) return false;
|
|
963
|
+
return Date.now() >= Number(expiresAt);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Clear stored token and auth state */
|
|
967
|
+
clearToken(): void {
|
|
968
|
+
this.storage.removeItem(this.keys.token);
|
|
969
|
+
this.storage.removeItem(this.keys.expiresAt);
|
|
970
|
+
this.storage.removeItem(this.keys.launchMode);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Full logout — clears local state and redirects to the IdP's
|
|
975
|
+
* end_session_endpoint so the Keycloak / OAuth2 session is terminated.
|
|
976
|
+
*
|
|
977
|
+
* Falls back to just clearing local state if no end_session_endpoint
|
|
978
|
+
* is advertised by the server.
|
|
979
|
+
*/
|
|
980
|
+
async logout(postLogoutRedirectUri?: string): Promise<void> {
|
|
981
|
+
const token = this.getToken();
|
|
982
|
+
this.clearToken();
|
|
983
|
+
|
|
984
|
+
try {
|
|
985
|
+
const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
|
|
986
|
+
if (endpoints.end_session_endpoint) {
|
|
987
|
+
const params = new URLSearchParams({
|
|
988
|
+
post_logout_redirect_uri:
|
|
989
|
+
postLogoutRedirectUri ??
|
|
990
|
+
this.config.postLogoutRedirectUri ??
|
|
991
|
+
window.location.origin,
|
|
992
|
+
client_id: this.config.clientId,
|
|
993
|
+
});
|
|
994
|
+
if (token?.id_token) {
|
|
995
|
+
params.set("id_token_hint", token.id_token);
|
|
996
|
+
}
|
|
997
|
+
window.location.href = \`\${endpoints.end_session_endpoint}?\${params}\`;
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
} catch {
|
|
1001
|
+
// discovery failed — fall through to local-only logout
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// No end_session_endpoint — just reload to the app root
|
|
1005
|
+
window.location.href = postLogoutRedirectUri ?? this.config.postLogoutRedirectUri ?? window.location.origin;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Refresh the access token using the refresh_token grant */
|
|
1009
|
+
async refreshAccessToken(): Promise<SmartToken | null> {
|
|
1010
|
+
const token = this.getToken();
|
|
1011
|
+
if (!token?.refresh_token) return null;
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
|
|
1015
|
+
const body = new URLSearchParams({
|
|
1016
|
+
grant_type: "refresh_token",
|
|
1017
|
+
refresh_token: token.refresh_token,
|
|
1018
|
+
client_id: this.config.clientId,
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
const res = await fetch(endpoints.token_endpoint, {
|
|
1022
|
+
method: "POST",
|
|
1023
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1024
|
+
body,
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
if (!res.ok) return null;
|
|
1028
|
+
|
|
1029
|
+
const newToken: SmartToken = await res.json();
|
|
1030
|
+
|
|
1031
|
+
// Preserve context from previous token if not in the new one
|
|
1032
|
+
if (!newToken.fhirUser && token.fhirUser) newToken.fhirUser = token.fhirUser;
|
|
1033
|
+
if (!newToken.refresh_token && token.refresh_token)
|
|
1034
|
+
newToken.refresh_token = token.refresh_token;
|
|
1035
|
+
if (!newToken.patient && token.patient) newToken.patient = token.patient;
|
|
1036
|
+
if (!newToken.encounter && token.encounter) newToken.encounter = token.encounter;
|
|
1037
|
+
|
|
1038
|
+
this.storage.setItem(this.keys.token, JSON.stringify(newToken));
|
|
1039
|
+
if (newToken.expires_in) {
|
|
1040
|
+
const expiresAt = Date.now() + (newToken.expires_in - 60) * 1000;
|
|
1041
|
+
this.storage.setItem(this.keys.expiresAt, String(expiresAt));
|
|
1042
|
+
}
|
|
1043
|
+
return newToken;
|
|
1044
|
+
} catch {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Get a valid (non-expired) token, refreshing if needed */
|
|
1050
|
+
async getValidToken(): Promise<SmartToken | null> {
|
|
1051
|
+
const token = this.getToken();
|
|
1052
|
+
if (!token) return null;
|
|
1053
|
+
if (!this.isTokenExpired()) return token;
|
|
1054
|
+
return this.refreshAccessToken();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ── Authenticated fetch ──────────────────────────────────────────────
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Create a fetch wrapper that injects the Bearer token.
|
|
1061
|
+
* Automatically refreshes expired tokens when a refresh_token is available.
|
|
1062
|
+
*
|
|
1063
|
+
* Pass the returned function to \`FhirClient\` as the \`fetchFn\` parameter.
|
|
1064
|
+
*
|
|
1065
|
+
* @example
|
|
1066
|
+
* const auth = new SmartAuth(config);
|
|
1067
|
+
* const client = new FhirClient(config.fhirBaseUrl, auth.createAuthenticatedFetch());
|
|
1068
|
+
*/
|
|
1069
|
+
createAuthenticatedFetch(): typeof globalThis.fetch {
|
|
1070
|
+
const self = this;
|
|
1071
|
+
|
|
1072
|
+
return async function authenticatedFetch(
|
|
1073
|
+
input: RequestInfo | URL,
|
|
1074
|
+
init?: RequestInit,
|
|
1075
|
+
): Promise<Response> {
|
|
1076
|
+
const token = await self.getValidToken();
|
|
1077
|
+
if (!token) {
|
|
1078
|
+
throw new Error("No valid SMART token available — authorize first");
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const headers = new Headers(init?.headers);
|
|
1082
|
+
headers.set("Authorization", \`Bearer \${token.access_token}\`);
|
|
1083
|
+
|
|
1084
|
+
const response = await fetch(input, { ...init, headers });
|
|
1085
|
+
|
|
1086
|
+
// Auto-retry once on 401 with a refreshed token
|
|
1087
|
+
if (response.status === 401 && token.refresh_token) {
|
|
1088
|
+
const refreshed = await self.refreshAccessToken();
|
|
1089
|
+
if (refreshed) {
|
|
1090
|
+
headers.set("Authorization", \`Bearer \${refreshed.access_token}\`);
|
|
1091
|
+
return fetch(input, { ...init, headers });
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return response;
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ── Internal ─────────────────────────────────────────────────────────
|
|
1100
|
+
|
|
1101
|
+
private async preparePkce(): Promise<{
|
|
1102
|
+
codeVerifier: string;
|
|
1103
|
+
codeChallenge: string;
|
|
1104
|
+
state: string;
|
|
1105
|
+
}> {
|
|
1106
|
+
const codeVerifier = generateRandomString(64);
|
|
1107
|
+
const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
|
|
1108
|
+
const state = generateRandomString(32);
|
|
1109
|
+
|
|
1110
|
+
this.storage.setItem(this.keys.verifier, codeVerifier);
|
|
1111
|
+
this.storage.setItem(this.keys.state, state);
|
|
1112
|
+
|
|
1113
|
+
return { codeVerifier, codeChallenge, state };
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
`;
|
|
1117
|
+
fs.writeFileSync(path.join(clientDir, "smart-auth.ts"), content);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Generate smart-client.ts — SmartFhirClient that wraps FhirClient with SMART auth
|
|
1121
|
+
*/
|
|
1122
|
+
function generateSmartClient(clientDir, resourceTypes) {
|
|
1123
|
+
const content = `import { FhirClient, FhirReadClient, FhirWriteClient } from "./fhir-client.js";
|
|
1124
|
+
import { SmartAuth } from "./smart-auth.js";
|
|
1125
|
+
import type { SmartConfig, SmartToken, LaunchMode } from "./smart-auth.js";
|
|
1126
|
+
|
|
1127
|
+
export type { SmartConfig, SmartToken, LaunchMode };
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* SMART on FHIR enabled FHIR Client.
|
|
1131
|
+
*
|
|
1132
|
+
* Combines \`SmartAuth\` (PKCE, discovery, token management) with the typed
|
|
1133
|
+
* \`FhirClient\` so every request is automatically authenticated.
|
|
1134
|
+
*
|
|
1135
|
+
* @example
|
|
1136
|
+
* const smart = new SmartFhirClient({
|
|
1137
|
+
* clientId: "my-app",
|
|
1138
|
+
* redirectUri: "http://localhost:3000/callback",
|
|
1139
|
+
* fhirBaseUrl: "https://fhir.example.com/fhir",
|
|
1140
|
+
* scopes: "openid fhirUser patient/*.read",
|
|
1141
|
+
* });
|
|
1142
|
+
*
|
|
1143
|
+
* // On page load — either handle callback or start auth
|
|
1144
|
+
* if (smart.isCallback()) {
|
|
1145
|
+
* await smart.handleCallback();
|
|
1146
|
+
* } else if (!smart.isAuthenticated()) {
|
|
1147
|
+
* await smart.authorize(); // redirects
|
|
1148
|
+
* return;
|
|
1149
|
+
* }
|
|
1150
|
+
*
|
|
1151
|
+
* // Use the typed FHIR client — auth headers injected automatically
|
|
1152
|
+
* const patient = await smart.client.read().patient().read("123");
|
|
1153
|
+
*/
|
|
1154
|
+
export class SmartFhirClient {
|
|
1155
|
+
/** The underlying SMART auth manager */
|
|
1156
|
+
public readonly auth: SmartAuth;
|
|
1157
|
+
|
|
1158
|
+
/** The typed FHIR client with automatic auth headers */
|
|
1159
|
+
public readonly client: FhirClient;
|
|
1160
|
+
|
|
1161
|
+
constructor(config: SmartConfig) {
|
|
1162
|
+
this.auth = new SmartAuth(config);
|
|
1163
|
+
this.client = new FhirClient(
|
|
1164
|
+
config.fhirBaseUrl,
|
|
1165
|
+
this.auth.createAuthenticatedFetch(),
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ── Convenience delegates ────────────────────────────────────────────
|
|
1170
|
+
|
|
1171
|
+
/** Start the authorization flow (auto-detects standalone vs EHR launch) */
|
|
1172
|
+
async authorize(): Promise<void> {
|
|
1173
|
+
return this.auth.authorize();
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** Check if the current page is an OAuth callback */
|
|
1177
|
+
isCallback(): boolean {
|
|
1178
|
+
return this.auth.isCallback();
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Handle the OAuth callback and store the token */
|
|
1182
|
+
async handleCallback(): Promise<SmartToken> {
|
|
1183
|
+
return this.auth.handleCallback();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** Check if the user has a stored token */
|
|
1187
|
+
isAuthenticated(): boolean {
|
|
1188
|
+
return this.auth.isAuthenticated();
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Get the current token (null if not authenticated) */
|
|
1192
|
+
getToken(): SmartToken | null {
|
|
1193
|
+
return this.auth.getToken();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** Get the launch mode (standalone or ehr) */
|
|
1197
|
+
getLaunchMode(): LaunchMode {
|
|
1198
|
+
return this.auth.getLaunchMode();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** Full logout — clears local state and ends the IdP session */
|
|
1202
|
+
async logout(postLogoutRedirectUri?: string): Promise<void> {
|
|
1203
|
+
return this.auth.logout(postLogoutRedirectUri);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ── Typed read/write access ──────────────────────────────────────────
|
|
1207
|
+
|
|
1208
|
+
/** Get the read client (all requests are authenticated) */
|
|
1209
|
+
read(): FhirReadClient {
|
|
1210
|
+
return this.client.read();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Get the write client (all requests are authenticated) */
|
|
1214
|
+
write(): FhirWriteClient {
|
|
1215
|
+
return this.client.write();
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
`;
|
|
1219
|
+
fs.writeFileSync(path.join(clientDir, "smart-client.ts"), content);
|
|
1220
|
+
}
|
|
587
1221
|
/**
|
|
588
1222
|
* Generate index.ts
|
|
589
1223
|
*/
|
|
@@ -596,6 +1230,7 @@ function generateIndex(clientDir, resourceTypes) {
|
|
|
596
1230
|
.join("\n");
|
|
597
1231
|
const content = `export { FhirClient, FhirReadClient, FhirWriteClient } from "./fhir-client.js";
|
|
598
1232
|
export { FhirResourceReader } from "./resource-reader.js";
|
|
1233
|
+
export type { FetchFn } from "./resource-reader.js";
|
|
599
1234
|
export type {
|
|
600
1235
|
${readerTypes}
|
|
601
1236
|
FhirResourceSearcher,
|
|
@@ -608,6 +1243,9 @@ ${writerTypes}
|
|
|
608
1243
|
export { BundleParser, parseBundle } from "./bundle-parser.js";
|
|
609
1244
|
export type { BundleEntry } from "./bundle-parser.js";
|
|
610
1245
|
export type { Bundle, FhirResource, SearchParams, WithId } from "./types.js";
|
|
1246
|
+
export { SmartAuth, discoverEndpoints } from "./smart-auth.js";
|
|
1247
|
+
export type { SmartConfig, SmartToken, SmartConfiguration, LaunchMode } from "./smart-auth.js";
|
|
1248
|
+
export { SmartFhirClient } from "./smart-client.js";
|
|
611
1249
|
`;
|
|
612
1250
|
fs.writeFileSync(path.join(clientDir, "index.ts"), content);
|
|
613
1251
|
}
|
|
@@ -617,7 +1255,7 @@ export type { Bundle, FhirResource, SearchParams, WithId } from "./types.js";
|
|
|
617
1255
|
function generateReadme(clientDir) {
|
|
618
1256
|
const content = `# FHIR Client
|
|
619
1257
|
|
|
620
|
-
Auto-generated type-safe FHIR client for TypeScript.
|
|
1258
|
+
Auto-generated type-safe FHIR client for TypeScript with SMART on FHIR authorization.
|
|
621
1259
|
|
|
622
1260
|
## Usage
|
|
623
1261
|
|
|
@@ -658,6 +1296,85 @@ const updated = await client.write().patient().update({
|
|
|
658
1296
|
await client.write().patient().delete('patient-id');
|
|
659
1297
|
\`\`\`
|
|
660
1298
|
|
|
1299
|
+
### SMART on FHIR Authorization
|
|
1300
|
+
|
|
1301
|
+
The client includes full SMART App Launch Framework (v2) support with PKCE.
|
|
1302
|
+
|
|
1303
|
+
#### Quick Start — SmartFhirClient
|
|
1304
|
+
|
|
1305
|
+
\`\`\`typescript
|
|
1306
|
+
import { SmartFhirClient } from './fhir-client';
|
|
1307
|
+
|
|
1308
|
+
const smart = new SmartFhirClient({
|
|
1309
|
+
clientId: 'my-app',
|
|
1310
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
1311
|
+
fhirBaseUrl: 'https://fhir.example.com/fhir',
|
|
1312
|
+
scopes: 'openid fhirUser patient/*.read',
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
// On page load — handle callback or start auth
|
|
1316
|
+
if (smart.isCallback()) {
|
|
1317
|
+
await smart.handleCallback();
|
|
1318
|
+
} else if (!smart.isAuthenticated()) {
|
|
1319
|
+
await smart.authorize(); // redirects to auth server
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// All requests include the Bearer token automatically
|
|
1324
|
+
const patient = await smart.read().patient().read('123');
|
|
1325
|
+
const token = smart.getToken(); // access SmartToken
|
|
1326
|
+
\`\`\`
|
|
1327
|
+
|
|
1328
|
+
#### Advanced — SmartAuth + FhirClient
|
|
1329
|
+
|
|
1330
|
+
For more control, use \`SmartAuth\` directly with \`FhirClient\`:
|
|
1331
|
+
|
|
1332
|
+
\`\`\`typescript
|
|
1333
|
+
import { SmartAuth, FhirClient } from './fhir-client';
|
|
1334
|
+
|
|
1335
|
+
const auth = new SmartAuth({
|
|
1336
|
+
clientId: 'my-app',
|
|
1337
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
1338
|
+
fhirBaseUrl: 'https://fhir.example.com/fhir',
|
|
1339
|
+
scopes: 'openid fhirUser patient/*.read launch/patient',
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
// Get an authenticated fetch function
|
|
1343
|
+
const authFetch = auth.createAuthenticatedFetch();
|
|
1344
|
+
|
|
1345
|
+
// Pass it to FhirClient
|
|
1346
|
+
const client = new FhirClient('https://fhir.example.com/fhir', authFetch);
|
|
1347
|
+
\`\`\`
|
|
1348
|
+
|
|
1349
|
+
#### EHR Launch
|
|
1350
|
+
|
|
1351
|
+
EHR launches are auto-detected from URL parameters (\`launch\` + \`iss\`):
|
|
1352
|
+
|
|
1353
|
+
\`\`\`typescript
|
|
1354
|
+
const smart = new SmartFhirClient({ ... });
|
|
1355
|
+
|
|
1356
|
+
// authorize() auto-detects and handles both modes
|
|
1357
|
+
await smart.authorize();
|
|
1358
|
+
|
|
1359
|
+
// After callback
|
|
1360
|
+
const mode = smart.getLaunchMode(); // "standalone" | "ehr"
|
|
1361
|
+
const token = smart.getToken();
|
|
1362
|
+
console.log('Patient context:', token?.patient);
|
|
1363
|
+
console.log('Encounter context:', token?.encounter);
|
|
1364
|
+
\`\`\`
|
|
1365
|
+
|
|
1366
|
+
#### Token Management
|
|
1367
|
+
|
|
1368
|
+
\`\`\`typescript
|
|
1369
|
+
// Check authentication state
|
|
1370
|
+
smart.isAuthenticated(); // boolean
|
|
1371
|
+
smart.getToken(); // SmartToken | null
|
|
1372
|
+
smart.getLaunchMode(); // "standalone" | "ehr"
|
|
1373
|
+
|
|
1374
|
+
// Logout
|
|
1375
|
+
smart.logout();
|
|
1376
|
+
\`\`\`
|
|
1377
|
+
|
|
661
1378
|
### Bundle Parsing
|
|
662
1379
|
|
|
663
1380
|
\`\`\`typescript
|
|
@@ -700,6 +1417,23 @@ for (const entry of entries) {
|
|
|
700
1417
|
console.log(\`Search score: \${entry.search?.score}\`);
|
|
701
1418
|
}
|
|
702
1419
|
\`\`\`
|
|
1420
|
+
|
|
1421
|
+
### Custom Fetch
|
|
1422
|
+
|
|
1423
|
+
You can pass any custom fetch function to \`FhirClient\` — useful for logging, retries, or custom auth:
|
|
1424
|
+
|
|
1425
|
+
\`\`\`typescript
|
|
1426
|
+
import { FhirClient } from './fhir-client';
|
|
1427
|
+
|
|
1428
|
+
const loggingFetch: typeof fetch = async (input, init) => {
|
|
1429
|
+
console.log('Request:', input);
|
|
1430
|
+
const response = await fetch(input, init);
|
|
1431
|
+
console.log('Response:', response.status);
|
|
1432
|
+
return response;
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
const client = new FhirClient('https://fhir.example.com/fhir', loggingFetch);
|
|
1436
|
+
\`\`\`
|
|
703
1437
|
`;
|
|
704
1438
|
fs.writeFileSync(path.join(clientDir, "README.md"), content);
|
|
705
1439
|
}
|
|
@@ -158,14 +158,13 @@ async function generateIndexFile(dir) {
|
|
|
158
158
|
const files = fs.readdirSync(dir, { withFileTypes: true });
|
|
159
159
|
const exports = [];
|
|
160
160
|
const exportedNames = new Set();
|
|
161
|
-
// First pass: collect all files and their
|
|
161
|
+
// First pass: collect all files and their exported names
|
|
162
162
|
const fileExports = new Map();
|
|
163
163
|
for (const file of files) {
|
|
164
164
|
if (file.isFile() && file.name.endsWith('.ts') && !file.name.endsWith('.d.ts') && file.name !== 'index.ts' && file.name !== 'RandomSupport.ts') {
|
|
165
165
|
const filePath = path.join(dir, file.name);
|
|
166
166
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
167
167
|
const names = new Set();
|
|
168
|
-
// Extract export names (interfaces, classes, functions, types)
|
|
169
168
|
const exportMatches = content.matchAll(/export\s+(?:interface|class|type|const|function)\s+(\w+)/g);
|
|
170
169
|
for (const match of exportMatches) {
|
|
171
170
|
names.add(match[1]);
|
|
@@ -173,26 +172,35 @@ async function generateIndexFile(dir) {
|
|
|
173
172
|
fileExports.set(file.name, names);
|
|
174
173
|
}
|
|
175
174
|
}
|
|
176
|
-
// Second pass:
|
|
175
|
+
// Second pass: export files, using selective named exports when there are conflicts
|
|
177
176
|
for (const file of files) {
|
|
178
177
|
if (file.isFile() && file.name.endsWith('.ts') && !file.name.endsWith('.d.ts') && file.name !== 'index.ts' && file.name !== 'RandomSupport.ts') {
|
|
179
178
|
const baseName = file.name.replace('.ts', '');
|
|
180
179
|
const fileNames = fileExports.get(file.name) || new Set();
|
|
181
|
-
//
|
|
182
|
-
|
|
180
|
+
// Find which names from this file conflict with already-exported names
|
|
181
|
+
const conflicting = new Set();
|
|
182
|
+
const unique = new Set();
|
|
183
183
|
for (const name of fileNames) {
|
|
184
184
|
if (exportedNames.has(name)) {
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
conflicting.add(name);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
unique.add(name);
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
if (conflicting.size === 0) {
|
|
192
|
+
// No conflicts — use simple wildcard export
|
|
191
193
|
exports.push(`export * from './${baseName}.js';`);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
}
|
|
195
|
+
else if (unique.size > 0) {
|
|
196
|
+
// Has conflicts but also has unique names — use selective named export
|
|
197
|
+
const names = [...unique].sort().join(', ');
|
|
198
|
+
exports.push(`export { ${names} } from './${baseName}.js';`);
|
|
199
|
+
}
|
|
200
|
+
// If all names conflict (unique.size === 0), skip entirely
|
|
201
|
+
// Track all names from this file as exported
|
|
202
|
+
for (const name of fileNames) {
|
|
203
|
+
exportedNames.add(name);
|
|
196
204
|
}
|
|
197
205
|
}
|
|
198
206
|
}
|
|
@@ -1280,7 +1288,7 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
|
|
|
1280
1288
|
license: 'MIT',
|
|
1281
1289
|
// fhirpath is required at runtime by validators; keep as peer so host decides version
|
|
1282
1290
|
peerDependencies: {
|
|
1283
|
-
'fhirpath': '^4.0.0'
|
|
1291
|
+
'fhirpath': '^3.0.0 || ^4.0.0'
|
|
1284
1292
|
},
|
|
1285
1293
|
// Type definitions for FHIR are needed for the .d.ts files
|
|
1286
1294
|
dependencies: {
|
|
@@ -1291,10 +1299,14 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
|
|
|
1291
1299
|
fs.writeFileSync(generatedPackageJsonPath, JSON.stringify(generatedPackageJson, null, 2));
|
|
1292
1300
|
logger.log(`Created package.json in generated folder: ${packageName}-generated@${packageVersion}`);
|
|
1293
1301
|
// Copy fhir-r4.d.ts ambient module declaration to support fhir/r4 imports
|
|
1294
|
-
//
|
|
1295
|
-
const
|
|
1302
|
+
// In dev: src/generator/ -> src/fhir-r4.d.ts. In compiled: out/src/generator/ -> out/fhir-r4.d.ts
|
|
1303
|
+
const _selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
1304
|
+
const fhirR4DtsSource = [
|
|
1305
|
+
path.join(_selfDir, '..', 'fhir-r4.d.ts'),
|
|
1306
|
+
path.join(_selfDir, '..', '..', 'fhir-r4.d.ts'),
|
|
1307
|
+
].find(p => fs.existsSync(p));
|
|
1296
1308
|
const fhirR4DtsDest = path.join(outputDir, 'fhir-r4.d.ts');
|
|
1297
|
-
if (
|
|
1309
|
+
if (fhirR4DtsSource) {
|
|
1298
1310
|
fs.copyFileSync(fhirR4DtsSource, fhirR4DtsDest);
|
|
1299
1311
|
logger.log('Copied fhir-r4.d.ts ambient module declaration');
|
|
1300
1312
|
}
|
package/out/src/main.js
CHANGED
|
@@ -5,11 +5,25 @@ import { getCacheConfig, getFhirPackagesCacheDir, clearAllCaches, ensureCacheDir
|
|
|
5
5
|
import { fetchArrayBuffer } from "./generator/fetchUtils.js";
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
|
-
import {
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
9
|
import { spawn } from 'child_process';
|
|
10
10
|
import { logger } from './logger.js';
|
|
11
|
-
|
|
12
|
-
const
|
|
11
|
+
// Resolve package.json relative to this file — works from both src/ (dev) and out/src/ (compiled)
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const _pkgJsonPath = [
|
|
14
|
+
path.resolve(__dirname, '../package.json'),
|
|
15
|
+
path.resolve(__dirname, '../../package.json'),
|
|
16
|
+
].find(p => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')).name === 'babelfhir-ts';
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
if (!_pkgJsonPath)
|
|
25
|
+
throw new Error('Cannot find babelfhir-ts package.json');
|
|
26
|
+
const packageJson = JSON.parse(fs.readFileSync(_pkgJsonPath, 'utf8'));
|
|
13
27
|
function printUsage() {
|
|
14
28
|
console.log("BabelFHIR-TS: Generate TypeScript interfaces from FHIR StructureDefinitions");
|
|
15
29
|
console.log("");
|
|
@@ -80,11 +94,27 @@ function cleanupCache() {
|
|
|
80
94
|
console.log('Cache cleaned.');
|
|
81
95
|
}
|
|
82
96
|
}
|
|
97
|
+
/** Detect the package manager used in the current working directory */
|
|
98
|
+
function detectPackageManager() {
|
|
99
|
+
const cwd = process.cwd();
|
|
100
|
+
const isWin = process.platform === 'win32';
|
|
101
|
+
if (fs.existsSync(path.join(cwd, 'bun.lock')) || fs.existsSync(path.join(cwd, 'bun.lockb'))) {
|
|
102
|
+
return { cmd: isWin ? 'bun.exe' : 'bun', args: ['add'] };
|
|
103
|
+
}
|
|
104
|
+
if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) {
|
|
105
|
+
return { cmd: isWin ? 'pnpm.cmd' : 'pnpm', args: ['add'] };
|
|
106
|
+
}
|
|
107
|
+
if (fs.existsSync(path.join(cwd, 'yarn.lock'))) {
|
|
108
|
+
return { cmd: isWin ? 'yarn.cmd' : 'yarn', args: ['add'] };
|
|
109
|
+
}
|
|
110
|
+
return { cmd: isWin ? 'npm.cmd' : 'npm', args: ['install'] };
|
|
111
|
+
}
|
|
83
112
|
function npmInstall(packagePath) {
|
|
84
113
|
return new Promise((resolve, reject) => {
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
114
|
+
const pm = detectPackageManager();
|
|
115
|
+
const pmName = path.basename(pm.cmd).replace(/\.(cmd|exe)$/, '');
|
|
116
|
+
console.log(`Installing package with ${pmName}...`);
|
|
117
|
+
const child = spawn(pm.cmd, [...pm.args, packagePath], {
|
|
88
118
|
stdio: 'inherit',
|
|
89
119
|
shell: true
|
|
90
120
|
});
|
|
@@ -94,11 +124,11 @@ function npmInstall(packagePath) {
|
|
|
94
124
|
resolve();
|
|
95
125
|
}
|
|
96
126
|
else {
|
|
97
|
-
reject(new Error(
|
|
127
|
+
reject(new Error(`${pmName} install failed with exit code ${code}`));
|
|
98
128
|
}
|
|
99
129
|
});
|
|
100
130
|
child.on('error', (err) => {
|
|
101
|
-
reject(new Error(`Failed to run
|
|
131
|
+
reject(new Error(`Failed to run ${pmName} install: ${err.message}`));
|
|
102
132
|
});
|
|
103
133
|
});
|
|
104
134
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "babelfhir-ts",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.37",
|
|
4
4
|
"description": "BabelFHIR-TS: generate TypeScript interfaces, validators, and helper classes from FHIR R4 StructureDefinitions (profiles) directly inside package archives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "out/src/main.js",
|