babelfhir-ts 1.4.1 → 1.4.3
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 +1 -0
- package/out/src/cli/installCommand.js +2 -1
- package/out/src/cli/updateCommand.js +91 -35
- package/out/src/generator/core/fetchUtils.js +47 -6
- package/out/src/generator/emitters/client/clientGenerator.js +98 -25
- package/out/src/generator/emitters/client/clientReadmeGenerator.js +12 -5
- package/out/src/generator/emitters/client/searchParamHelpers.js +37 -0
- package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +4 -0
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +10 -2
- package/out/src/generator/index.js +55 -6
- package/out/src/generator/parser/packageParser.js +62 -1
- package/out/src/main.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -234,6 +234,7 @@ Options:
|
|
|
234
234
|
--schema <format> Generate schema files alongside outputs (supported: zod)
|
|
235
235
|
--dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG
|
|
236
236
|
--recursive (update only) Recursively search subdirectories for lib/ folders
|
|
237
|
+
--force (update only) Force regeneration even if version and flags haven't changed
|
|
237
238
|
--outDir <dir> Output directory (alias for second positional argument)
|
|
238
239
|
--fhir-version <ver> FHIR version to target: r4, r4b, or r5 (auto-detected from package if omitted)
|
|
239
240
|
--package <pkg[@version]> Download FHIR package from registry and process it (latest if no version)
|
|
@@ -105,9 +105,10 @@ function npmInstall(packagePath) {
|
|
|
105
105
|
}
|
|
106
106
|
const { args } = result;
|
|
107
107
|
console.log(`Installing package with ${pmName}...`);
|
|
108
|
+
// shell required on Windows for .cmd wrappers; disabled on Unix to prevent injection
|
|
108
109
|
const child = spawn(pm.cmd, args, {
|
|
109
110
|
stdio: 'inherit',
|
|
110
|
-
shell:
|
|
111
|
+
shell: process.platform === 'win32'
|
|
111
112
|
});
|
|
112
113
|
child.on('exit', (code) => {
|
|
113
114
|
if (code === 0) {
|
|
@@ -13,14 +13,14 @@ import fs from 'fs';
|
|
|
13
13
|
import os from 'os';
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import crypto from 'crypto';
|
|
16
|
-
import { generateIntoPackageDirect, resetFetchFailureTracking } from '../generator/index.js';
|
|
16
|
+
import { generateIntoPackageDirect, resetFetchFailureTracking, GENERATOR_VERSION } from '../generator/index.js';
|
|
17
17
|
import { getFhirPackagesCacheDir } from '../generator/core/cacheConfig.js';
|
|
18
18
|
import { resetDiagnostics, buildQualityReport, formatReportSummary } from '../generator/core/sdDiagnostics.js';
|
|
19
19
|
import { detectPackageManager } from './installCommand.js';
|
|
20
20
|
import { spawn } from 'child_process';
|
|
21
21
|
// ── Public entry point ──────────────────────────────────────────────────────
|
|
22
22
|
export async function handleUpdateCommand(opts) {
|
|
23
|
-
const { packageFilter, recursive, generationFlags, registry, downloadPackage, confirm } = opts;
|
|
23
|
+
const { packageFilter, recursive, force, generationFlags, registry, downloadPackage, confirm } = opts;
|
|
24
24
|
const startDir = process.cwd();
|
|
25
25
|
// 1. Discover tgz files
|
|
26
26
|
const libDirs = discoverLibDirs(startDir, recursive ?? false);
|
|
@@ -55,11 +55,17 @@ export async function handleUpdateCommand(opts) {
|
|
|
55
55
|
for (const t of targets)
|
|
56
56
|
console.log(` ${t.igName}@${t.igVersion} → ${path.relative(startDir, t.tgzPath)}`);
|
|
57
57
|
console.log('');
|
|
58
|
-
// 4. Regenerate each package
|
|
58
|
+
// 4. Regenerate each package (skip if already up to date)
|
|
59
59
|
let updated = 0;
|
|
60
|
+
let skipped = 0;
|
|
60
61
|
let failed = 0;
|
|
61
62
|
const updatedPackages = [];
|
|
62
63
|
for (const target of targets) {
|
|
64
|
+
if (!force && isUpToDate(target, generationFlags)) {
|
|
65
|
+
skipped++;
|
|
66
|
+
console.log(`✓ ${target.igName}@${target.igVersion} — already up to date (babelfhir-ts ${target.generatorVersion})`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
63
69
|
try {
|
|
64
70
|
await regeneratePackage(target, generationFlags, registry, downloadPackage);
|
|
65
71
|
updatedPackages.push({ generatedName: target.generatedName, tgzPath: target.tgzPath });
|
|
@@ -70,25 +76,34 @@ export async function handleUpdateCommand(opts) {
|
|
|
70
76
|
console.error(`✗ Failed to update ${target.igName}@${target.igVersion}: ${err.message}`);
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
|
-
// 5.
|
|
79
|
+
// 5. Remove stale node_modules/<pkg> so bun re-extracts from the updated tgz
|
|
74
80
|
if (updatedPackages.length > 0) {
|
|
75
|
-
|
|
81
|
+
removeStaleNodeModules(updatedPackages.map((p) => p.generatedName));
|
|
76
82
|
}
|
|
77
|
-
//
|
|
83
|
+
// 6. Patch bun.lock integrity hashes BEFORE install so bun sees matching hashes
|
|
78
84
|
if (updatedPackages.length > 0) {
|
|
79
|
-
|
|
85
|
+
patchBunLockIntegrity(updatedPackages);
|
|
80
86
|
}
|
|
81
|
-
//
|
|
87
|
+
// 7. Single npm install at the end
|
|
82
88
|
if (updated > 0) {
|
|
83
89
|
console.log('\nReinstalling dependencies...');
|
|
84
90
|
await runInstall();
|
|
85
91
|
}
|
|
86
|
-
//
|
|
92
|
+
// 8. Re-patch bun.lock integrity after install (bun may recompute hashes)
|
|
93
|
+
if (updatedPackages.length > 0) {
|
|
94
|
+
patchBunLockIntegrity(updatedPackages);
|
|
95
|
+
}
|
|
96
|
+
// 9. Summary
|
|
87
97
|
console.log('');
|
|
88
98
|
if (updated > 0)
|
|
89
99
|
console.log(`✓ Updated ${updated} package${updated === 1 ? '' : 's'}`);
|
|
100
|
+
if (skipped > 0)
|
|
101
|
+
console.log(`✓ ${skipped} package${skipped === 1 ? '' : 's'} already up to date`);
|
|
90
102
|
if (failed > 0)
|
|
91
103
|
console.error(`✗ ${failed} package${failed === 1 ? '' : 's'} failed`);
|
|
104
|
+
if (skipped > 0 && updated === 0 && failed === 0) {
|
|
105
|
+
console.log(' Use --force to regenerate anyway.');
|
|
106
|
+
}
|
|
92
107
|
return {};
|
|
93
108
|
}
|
|
94
109
|
// ── Install redirect prompt ─────────────────────────────────────────────────
|
|
@@ -200,10 +215,15 @@ async function readTgzMetadata(tgzPath, listFn) {
|
|
|
200
215
|
igVersion,
|
|
201
216
|
...(pkg?.fhir?.txServer && { txServer: pkg.fhir.txServer }),
|
|
202
217
|
...(pkg?.fhir?.displayLanguage && { displayLanguage: pkg.fhir.displayLanguage }),
|
|
218
|
+
...(pkg?.fhir?.fhirVersion && { fhirVersion: pkg.fhir.fhirVersion }),
|
|
203
219
|
...(pkg?.fhir?.dicomweb && { dicomweb: true }),
|
|
204
220
|
...(pkg?.fhir?.noClient && { noClient: true }),
|
|
205
221
|
...(pkg?.fhir?.noClasses && { noClasses: true }),
|
|
206
222
|
...(pkg?.fhir?.schema && { schema: pkg.fhir.schema }),
|
|
223
|
+
// Extract generator version from devDependencies (e.g. "^1.4.2" → "1.4.2")
|
|
224
|
+
...(pkg?.devDependencies?.['babelfhir-ts'] && {
|
|
225
|
+
generatorVersion: pkg.devDependencies['babelfhir-ts'].replace(/^[^\d]*/, ''),
|
|
226
|
+
}),
|
|
207
227
|
};
|
|
208
228
|
}
|
|
209
229
|
catch {
|
|
@@ -222,6 +242,32 @@ function filterBySpec(all, spec) {
|
|
|
222
242
|
// No version — match by name only
|
|
223
243
|
return all.filter(t => t.igName === spec);
|
|
224
244
|
}
|
|
245
|
+
// ── Up-to-date check ────────────────────────────────────────────────────────
|
|
246
|
+
/**
|
|
247
|
+
* Check whether a package was already generated with the current babelfhir-ts
|
|
248
|
+
* version and the same effective flags. Returns true if regeneration can be skipped.
|
|
249
|
+
*/
|
|
250
|
+
function isUpToDate(target, cliFlags) {
|
|
251
|
+
// No stored version → was generated before this feature existed → regenerate
|
|
252
|
+
if (!target.generatorVersion)
|
|
253
|
+
return false;
|
|
254
|
+
// Compare generator versions (strip pre-release suffixes for comparison: 1.4.2-dev.xxx → 1.4.2)
|
|
255
|
+
const storedMajorMinorPatch = target.generatorVersion.split('-')[0];
|
|
256
|
+
const currentMajorMinorPatch = GENERATOR_VERSION.split('-')[0];
|
|
257
|
+
if (storedMajorMinorPatch !== currentMajorMinorPatch)
|
|
258
|
+
return false;
|
|
259
|
+
// Compare generation flags that affect output
|
|
260
|
+
const flagKeys = ['txServer', 'displayLanguage', 'fhirVersion', 'dicomweb', 'noClient', 'noClasses', 'schema'];
|
|
261
|
+
for (const key of flagKeys) {
|
|
262
|
+
const stored = target[key];
|
|
263
|
+
const cli = cliFlags[key];
|
|
264
|
+
// CLI flag explicitly set and differs from stored → needs regeneration
|
|
265
|
+
if (cli !== undefined && cli !== null && cli !== false && cli !== '' && String(cli) !== String(stored ?? '')) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
225
271
|
// ── Regeneration ────────────────────────────────────────────────────────────
|
|
226
272
|
async function regeneratePackage(target, generationFlags, registry, downloadPackage) {
|
|
227
273
|
const spec = `${target.igName}@${target.igVersion}`;
|
|
@@ -238,6 +284,10 @@ async function regeneratePackage(target, generationFlags, registry, downloadPack
|
|
|
238
284
|
mergedFlags.displayLanguage = target.displayLanguage;
|
|
239
285
|
console.log(` Using display-language from package metadata: ${target.displayLanguage}`);
|
|
240
286
|
}
|
|
287
|
+
if (target.fhirVersion && !mergedFlags.fhirVersion) {
|
|
288
|
+
mergedFlags.fhirVersion = target.fhirVersion;
|
|
289
|
+
console.log(` Using fhir-version from package metadata: ${target.fhirVersion}`);
|
|
290
|
+
}
|
|
241
291
|
if (target.dicomweb && mergedFlags.dicomweb === undefined)
|
|
242
292
|
mergedFlags.dicomweb = true;
|
|
243
293
|
if (target.noClient && mergedFlags.noClient === undefined)
|
|
@@ -303,41 +353,46 @@ export function patchBunLockIntegrity(updatedPackages) {
|
|
|
303
353
|
const pm = detectPackageManager();
|
|
304
354
|
if (!pm.cmd.includes('bun'))
|
|
305
355
|
return;
|
|
306
|
-
//
|
|
307
|
-
|
|
356
|
+
// Collect ALL bun.lock files walking up (monorepos may have one at the
|
|
357
|
+
// workspace root AND one in a nested app directory).
|
|
358
|
+
const lockPaths = [];
|
|
308
359
|
let dir = process.cwd();
|
|
309
360
|
while (true) {
|
|
310
361
|
const candidate = path.join(dir, 'bun.lock');
|
|
311
|
-
if (fs.existsSync(candidate))
|
|
312
|
-
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
362
|
+
if (fs.existsSync(candidate))
|
|
363
|
+
lockPaths.push(candidate);
|
|
315
364
|
const parent = path.dirname(dir);
|
|
316
365
|
if (parent === dir)
|
|
317
366
|
break;
|
|
318
367
|
dir = parent;
|
|
319
368
|
}
|
|
320
|
-
if (
|
|
369
|
+
if (lockPaths.length === 0)
|
|
321
370
|
return;
|
|
322
|
-
let
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
371
|
+
let totalPatched = 0;
|
|
372
|
+
for (const lockPath of lockPaths) {
|
|
373
|
+
let lockContent = fs.readFileSync(lockPath, 'utf8');
|
|
374
|
+
let patched = 0;
|
|
375
|
+
for (const { generatedName, tgzPath } of updatedPackages) {
|
|
376
|
+
// Compute new SHA-512 integrity hash
|
|
377
|
+
const tgzBytes = fs.readFileSync(tgzPath);
|
|
378
|
+
const hash = crypto.createHash('sha512').update(tgzBytes).digest('base64');
|
|
379
|
+
const newIntegrity = `sha512-${hash}`;
|
|
380
|
+
// Match entries with existing integrity hashes and replace them:
|
|
381
|
+
// "hl7.fhir.uv.ips-generated": [..., "sha512-oldHash=="],
|
|
382
|
+
const pattern = new RegExp(`("(?:[^"]*\\/)?${escapeRegExp(generatedName)}"\\s*:\\s*\\[.*?)(sha512-[A-Za-z0-9+/]+=*)`, 'g');
|
|
383
|
+
lockContent = lockContent.replace(pattern, (_, prefix, _oldHash) => {
|
|
384
|
+
patched++;
|
|
385
|
+
return `${prefix}${newIntegrity}`;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (patched > 0) {
|
|
389
|
+
fs.writeFileSync(lockPath, lockContent, 'utf8');
|
|
390
|
+
totalPatched += patched;
|
|
391
|
+
console.log(`Patched ${patched} integrity hash${patched === 1 ? '' : 'es'} in ${path.relative(process.cwd(), lockPath) || 'bun.lock'}`);
|
|
392
|
+
}
|
|
337
393
|
}
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
console.log(`Patched ${patched} integrity hash${patched === 1 ? '' : 'es'} in bun.lock`);
|
|
394
|
+
if (totalPatched === 0) {
|
|
395
|
+
console.log('No integrity hashes found to patch in bun.lock');
|
|
341
396
|
}
|
|
342
397
|
}
|
|
343
398
|
function escapeRegExp(s) {
|
|
@@ -437,7 +492,8 @@ function runInstall() {
|
|
|
437
492
|
const pm = detectPackageManager();
|
|
438
493
|
const pmName = path.basename(pm.cmd).replace(/\.(cmd|exe)$/, '');
|
|
439
494
|
console.log(`Running ${pmName} install...`);
|
|
440
|
-
|
|
495
|
+
// shell required on Windows for .cmd wrappers; disabled on Unix to prevent injection
|
|
496
|
+
const child = spawn(pm.cmd, ['install'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
441
497
|
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`${pmName} install failed (exit ${code})`)));
|
|
442
498
|
child.on('error', (err) => reject(new Error(`Failed to run ${pmName}: ${err.message}`)));
|
|
443
499
|
});
|
|
@@ -5,6 +5,37 @@
|
|
|
5
5
|
import http from "http";
|
|
6
6
|
import https from "https";
|
|
7
7
|
import { USER_AGENT, FETCH_TIMEOUT_MS } from './constants.js';
|
|
8
|
+
/** Maximum number of HTTP redirects to follow before aborting. */
|
|
9
|
+
const MAX_REDIRECTS = 10;
|
|
10
|
+
/**
|
|
11
|
+
* Validate that a URL uses an allowed scheme (http/https) and does not target
|
|
12
|
+
* private/internal network addresses (SSRF mitigation).
|
|
13
|
+
*/
|
|
14
|
+
export function assertSafeUrl(url) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(url);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
21
|
+
}
|
|
22
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
23
|
+
throw new Error(`Blocked request to non-HTTP URL: ${url}`);
|
|
24
|
+
}
|
|
25
|
+
const host = parsed.hostname.toLowerCase();
|
|
26
|
+
if (host === 'localhost' ||
|
|
27
|
+
host === '127.0.0.1' ||
|
|
28
|
+
host === '::1' ||
|
|
29
|
+
host === '0.0.0.0' ||
|
|
30
|
+
host.startsWith('169.254.') ||
|
|
31
|
+
host.startsWith('10.') ||
|
|
32
|
+
host.startsWith('192.168.') ||
|
|
33
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
34
|
+
host.endsWith('.internal') ||
|
|
35
|
+
host.endsWith('.local')) {
|
|
36
|
+
throw new Error(`Blocked request to private/internal address: ${host}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
8
39
|
/**
|
|
9
40
|
* Default headers for FHIR API requests
|
|
10
41
|
*/
|
|
@@ -84,15 +115,20 @@ export async function fetchArrayBuffer(url, options = {}) {
|
|
|
84
115
|
* Fetch with native https module for streaming (used by downloadFile)
|
|
85
116
|
* This provides better control for large file downloads
|
|
86
117
|
*/
|
|
87
|
-
export function fetchStream(url) {
|
|
118
|
+
export function fetchStream(url, redirectCount = 0) {
|
|
119
|
+
assertSafeUrl(url);
|
|
88
120
|
return new Promise((resolve, reject) => {
|
|
89
121
|
const client = url.startsWith('https') ? https : http;
|
|
90
122
|
client.get(url, {
|
|
91
123
|
headers: FHIR_HEADERS
|
|
92
124
|
}, (response) => {
|
|
93
|
-
// Handle redirects
|
|
125
|
+
// Handle redirects with depth limit
|
|
94
126
|
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
95
|
-
|
|
127
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
128
|
+
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
fetchStream(response.headers.location, redirectCount + 1).then(resolve).catch(reject);
|
|
96
132
|
return;
|
|
97
133
|
}
|
|
98
134
|
if (response.statusCode !== 200) {
|
|
@@ -107,7 +143,8 @@ export function fetchStream(url) {
|
|
|
107
143
|
* Fetch JSON using native https module with timeout support
|
|
108
144
|
* Used for fallback URLs where we need more control
|
|
109
145
|
*/
|
|
110
|
-
export function fetchJsonNative(url, options = {}) {
|
|
146
|
+
export function fetchJsonNative(url, options = {}, redirectCount = 0) {
|
|
147
|
+
assertSafeUrl(url);
|
|
111
148
|
return new Promise((resolve, reject) => {
|
|
112
149
|
const client = url.startsWith('https') ? https : http;
|
|
113
150
|
const req = client.get(url, {
|
|
@@ -117,9 +154,13 @@ export function fetchJsonNative(url, options = {}) {
|
|
|
117
154
|
},
|
|
118
155
|
timeout: options.timeout || FETCH_TIMEOUT_MS
|
|
119
156
|
}, (res) => {
|
|
120
|
-
// Handle redirects
|
|
157
|
+
// Handle redirects with depth limit
|
|
121
158
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
122
|
-
|
|
159
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
160
|
+
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
fetchJsonNative(res.headers.location, options, redirectCount + 1).then(resolve).catch(reject);
|
|
123
164
|
return;
|
|
124
165
|
}
|
|
125
166
|
if (res.statusCode !== 200) {
|
|
@@ -5,6 +5,7 @@ import { logger } from "../../../logger.js";
|
|
|
5
5
|
import { generateSmartAuth, generateSmartClient } from './smartAuthGenerator.js';
|
|
6
6
|
import { generateReadme, installBaseClientTypes } from './clientReadmeGenerator.js';
|
|
7
7
|
import { versionSlug } from '../../fhir/versionContext.js';
|
|
8
|
+
import { buildSearchParamInterfaces, getSearchParamsTypeName } from './searchParamHelpers.js';
|
|
8
9
|
const log = logger.withTag('client');
|
|
9
10
|
/**
|
|
10
11
|
* Generate FHIR client code for all resource types
|
|
@@ -26,14 +27,14 @@ export function generateClient(options) {
|
|
|
26
27
|
fs.mkdirSync(clientDir, { recursive: true });
|
|
27
28
|
}
|
|
28
29
|
// Generate each file
|
|
29
|
-
generateTypes(clientDir, resourceTypes);
|
|
30
|
-
generateResourceReader(clientDir, resourceTypes);
|
|
30
|
+
generateTypes(clientDir, resourceTypes, options.searchParams);
|
|
31
|
+
generateResourceReader(clientDir, resourceTypes, options.searchParams);
|
|
31
32
|
generateResourceWriter(clientDir, resourceTypes);
|
|
32
33
|
generateBundleParser(clientDir, resourceTypes);
|
|
33
|
-
generateFhirClient(clientDir, resourceTypes);
|
|
34
|
+
generateFhirClient(clientDir, resourceTypes, options.searchParams);
|
|
34
35
|
generateSmartAuth(clientDir);
|
|
35
36
|
generateSmartClient(clientDir, resourceTypes);
|
|
36
|
-
generateIndex(clientDir, resourceTypes);
|
|
37
|
+
generateIndex(clientDir, resourceTypes, options.searchParams);
|
|
37
38
|
generateReadme(clientDir);
|
|
38
39
|
// Install base client type declarations so tsc can resolve @babelfhir-ts/client-<version>
|
|
39
40
|
installBaseClientTypes(options.outputDir);
|
|
@@ -42,10 +43,12 @@ export function generateClient(options) {
|
|
|
42
43
|
/**
|
|
43
44
|
* Generate types.ts
|
|
44
45
|
*/
|
|
45
|
-
function generateTypes(clientDir, resourceTypes) {
|
|
46
|
+
function generateTypes(clientDir, resourceTypes, searchParams) {
|
|
46
47
|
const unionType = resourceTypes
|
|
47
48
|
.map((rt, idx) => ` ${idx === 0 ? "" : "| "}GeneratedTypes.${rt.profileName}`)
|
|
48
49
|
.join("\n");
|
|
50
|
+
// Generate per-base-resource-type search param interfaces
|
|
51
|
+
const searchParamInterfaces = buildSearchParamInterfaces(resourceTypes, searchParams);
|
|
49
52
|
const content = `import type * as GeneratedTypes from "../index.js";
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -54,9 +57,34 @@ function generateTypes(clientDir, resourceTypes) {
|
|
|
54
57
|
export type WithId<T> = { id: string } & T;
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
|
-
* Search
|
|
60
|
+
* Search parameter value type — all values are serialized to query strings.
|
|
58
61
|
*/
|
|
59
|
-
export type
|
|
62
|
+
export type SearchParamValue = boolean | number | string | string[] | undefined;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Search parameters for FHIR resources (generic untyped version).
|
|
66
|
+
*/
|
|
67
|
+
export type SearchParams = Record<string, SearchParamValue>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Common search parameters supported by all FHIR resources.
|
|
71
|
+
* Includes an index signature to allow search parameter modifiers (e.g. name:exact).
|
|
72
|
+
*/
|
|
73
|
+
export interface CommonSearchParams {
|
|
74
|
+
_id?: SearchParamValue;
|
|
75
|
+
_lastUpdated?: SearchParamValue;
|
|
76
|
+
_tag?: SearchParamValue;
|
|
77
|
+
_profile?: SearchParamValue;
|
|
78
|
+
_security?: SearchParamValue;
|
|
79
|
+
_count?: SearchParamValue;
|
|
80
|
+
_sort?: SearchParamValue;
|
|
81
|
+
_include?: SearchParamValue;
|
|
82
|
+
_revinclude?: SearchParamValue;
|
|
83
|
+
_summary?: SearchParamValue;
|
|
84
|
+
_elements?: SearchParamValue;
|
|
85
|
+
_total?: SearchParamValue;
|
|
86
|
+
[key: string]: SearchParamValue;
|
|
87
|
+
}
|
|
60
88
|
|
|
61
89
|
/**
|
|
62
90
|
* Bundle of FHIR resources
|
|
@@ -80,24 +108,34 @@ export interface Bundle<T> {
|
|
|
80
108
|
*/
|
|
81
109
|
export type FhirResource =
|
|
82
110
|
${unionType};
|
|
83
|
-
|
|
111
|
+
|
|
112
|
+
${searchParamInterfaces}`;
|
|
84
113
|
fs.writeFileSync(path.join(clientDir, "types.ts"), content);
|
|
85
114
|
}
|
|
86
115
|
/**
|
|
87
116
|
* Generate resource-reader.ts
|
|
88
117
|
*/
|
|
89
|
-
function generateResourceReader(clientDir, resourceTypes) {
|
|
118
|
+
function generateResourceReader(clientDir, resourceTypes, searchParams) {
|
|
119
|
+
// Build reader type aliases with typed search params
|
|
90
120
|
const readerTypes = resourceTypes
|
|
91
|
-
.map((rt) =>
|
|
121
|
+
.map((rt) => {
|
|
122
|
+
const spType = getSearchParamsTypeName(rt.baseResourceType, searchParams);
|
|
123
|
+
return `export type ${rt.profileName}Reader = FhirResourceSearcher<GeneratedTypes.${rt.profileName}, ${spType}>;`;
|
|
124
|
+
})
|
|
92
125
|
.join("\n");
|
|
126
|
+
// Collect unique SearchParams type imports from types.ts
|
|
127
|
+
const spTypeImports = new Set(['SearchParams']);
|
|
128
|
+
for (const rt of resourceTypes) {
|
|
129
|
+
spTypeImports.add(getSearchParamsTypeName(rt.baseResourceType, searchParams));
|
|
130
|
+
}
|
|
93
131
|
const content = `import type * as GeneratedTypes from "../index.js";
|
|
94
132
|
|
|
95
|
-
import type { Bundle,
|
|
133
|
+
import type { Bundle, ${[...spTypeImports].sort().join(', ')}, WithId } from "./types.js";
|
|
96
134
|
|
|
97
135
|
/**
|
|
98
136
|
* Generic FHIR resource searcher/reader
|
|
99
137
|
*/
|
|
100
|
-
export interface FhirResourceSearcher<T> {
|
|
138
|
+
export interface FhirResourceSearcher<T, S extends SearchParams = SearchParams> {
|
|
101
139
|
readonly baseUrl: string;
|
|
102
140
|
readonly resourceType: string;
|
|
103
141
|
|
|
@@ -109,17 +147,17 @@ export interface FhirResourceSearcher<T> {
|
|
|
109
147
|
/**
|
|
110
148
|
* Search for resources
|
|
111
149
|
*/
|
|
112
|
-
search(params?:
|
|
150
|
+
search(params?: S): Promise<Bundle<WithId<T>>>;
|
|
113
151
|
|
|
114
152
|
/**
|
|
115
153
|
* Search and return first result or undefined
|
|
116
154
|
*/
|
|
117
|
-
searchOne(params?:
|
|
155
|
+
searchOne(params?: S): Promise<undefined | WithId<T>>;
|
|
118
156
|
|
|
119
157
|
/**
|
|
120
158
|
* Search and return all results (handles pagination)
|
|
121
159
|
*/
|
|
122
|
-
searchAll(params?:
|
|
160
|
+
searchAll(params?: S): Promise<WithId<T>[]>;
|
|
123
161
|
}
|
|
124
162
|
|
|
125
163
|
/**
|
|
@@ -135,7 +173,7 @@ export type FetchFn = typeof globalThis.fetch;
|
|
|
135
173
|
/**
|
|
136
174
|
* Implementation of FHIR resource reader
|
|
137
175
|
*/
|
|
138
|
-
export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
|
|
176
|
+
export class FhirResourceReader<T, S extends SearchParams = SearchParams> implements FhirResourceSearcher<T, S> {
|
|
139
177
|
private readonly fetchFn: FetchFn;
|
|
140
178
|
|
|
141
179
|
constructor(
|
|
@@ -163,7 +201,7 @@ export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
|
|
|
163
201
|
return (await response.json()) as WithId<T>;
|
|
164
202
|
}
|
|
165
203
|
|
|
166
|
-
async search(params?:
|
|
204
|
+
async search(params?: S): Promise<Bundle<WithId<T>>> {
|
|
167
205
|
const url = new URL(\`\${this.baseUrl}/\${this.resourceType}\`);
|
|
168
206
|
|
|
169
207
|
if (params) {
|
|
@@ -195,12 +233,12 @@ export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
|
|
|
195
233
|
return (await response.json()) as Bundle<WithId<T>>;
|
|
196
234
|
}
|
|
197
235
|
|
|
198
|
-
async searchOne(params?:
|
|
199
|
-
const bundle = await this.search({ ...params, _count: 1 });
|
|
236
|
+
async searchOne(params?: S): Promise<undefined | WithId<T>> {
|
|
237
|
+
const bundle = await this.search({ ...params, _count: 1 } as S & { _count: number });
|
|
200
238
|
return bundle.entry?.[0]?.resource;
|
|
201
239
|
}
|
|
202
240
|
|
|
203
|
-
async searchAll(params?:
|
|
241
|
+
async searchAll(params?: S): Promise<WithId<T>[]> {
|
|
204
242
|
const results: WithId<T>[] = [];
|
|
205
243
|
let bundle = await this.search(params);
|
|
206
244
|
|
|
@@ -364,6 +402,7 @@ export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
|
|
|
364
402
|
* Generate bundle-parser.ts
|
|
365
403
|
*/
|
|
366
404
|
function generateBundleParser(clientDir, resourceTypes) {
|
|
405
|
+
const clientPkg = `@babelfhir-ts/client-${versionSlug()}`;
|
|
367
406
|
// Group profiles by base resource type
|
|
368
407
|
const resourceTypeGroups = new Map();
|
|
369
408
|
for (const rt of resourceTypes) {
|
|
@@ -382,6 +421,7 @@ function generateBundleParser(clientDir, resourceTypes) {
|
|
|
382
421
|
})
|
|
383
422
|
.join("\n");
|
|
384
423
|
const content = `import type * as GeneratedTypes from "../index.js";
|
|
424
|
+
import { BundleParser as BaseBundleParser } from "${clientPkg}";
|
|
385
425
|
import type { Bundle, FhirResource } from "./types.js";
|
|
386
426
|
|
|
387
427
|
/**
|
|
@@ -515,6 +555,15 @@ ${parseByTypeCases}
|
|
|
515
555
|
getBundleType(): 'collection' | 'searchset' | 'transaction-response' | 'transaction' | undefined {
|
|
516
556
|
return this.bundle.type;
|
|
517
557
|
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Resolve a FHIR reference within this Bundle.
|
|
561
|
+
* Supports "ResourceType/id" relative references and full-URL references
|
|
562
|
+
* matched against entry.fullUrl.
|
|
563
|
+
*/
|
|
564
|
+
resolveReference<T extends FhirResource>(ref: { reference?: string } | undefined): T | undefined {
|
|
565
|
+
return BaseBundleParser.resolveReference(ref, this.bundle as any) as T | undefined;
|
|
566
|
+
}
|
|
518
567
|
}
|
|
519
568
|
|
|
520
569
|
/**
|
|
@@ -529,12 +578,20 @@ export function parseBundle(bundle: Bundle<FhirResource>): BundleParser {
|
|
|
529
578
|
/**
|
|
530
579
|
* Generate fhir-client.ts
|
|
531
580
|
*/
|
|
532
|
-
function generateFhirClient(clientDir, resourceTypes) {
|
|
581
|
+
function generateFhirClient(clientDir, resourceTypes, searchParams) {
|
|
582
|
+
// Collect search param type imports
|
|
583
|
+
const spTypeImports = new Set();
|
|
584
|
+
for (const rt of resourceTypes) {
|
|
585
|
+
const spType = getSearchParamsTypeName(rt.baseResourceType, searchParams);
|
|
586
|
+
if (spType !== 'SearchParams')
|
|
587
|
+
spTypeImports.add(spType);
|
|
588
|
+
}
|
|
533
589
|
const readerMethods = resourceTypes
|
|
534
590
|
.map((rt) => {
|
|
535
591
|
const methodName = rt.profileName.charAt(0).toLowerCase() + rt.profileName.slice(1);
|
|
592
|
+
const spType = getSearchParamsTypeName(rt.baseResourceType, searchParams);
|
|
536
593
|
return ` ${methodName}() {
|
|
537
|
-
return this.forType<GeneratedTypes.${rt.profileName}>("${rt.baseResourceType}");
|
|
594
|
+
return this.forType<GeneratedTypes.${rt.profileName}, ${spType}>("${rt.baseResourceType}");
|
|
538
595
|
}`;
|
|
539
596
|
})
|
|
540
597
|
.join("\n\n");
|
|
@@ -548,13 +605,17 @@ function generateFhirClient(clientDir, resourceTypes) {
|
|
|
548
605
|
.join("\n\n");
|
|
549
606
|
const slug = versionSlug();
|
|
550
607
|
const clientPkg = `@babelfhir-ts/client-${slug}`;
|
|
608
|
+
const spImportLine = spTypeImports.size > 0
|
|
609
|
+
? `\nimport type { ${[...spTypeImports].sort().join(', ')} } from "./types.js";\n`
|
|
610
|
+
: '';
|
|
551
611
|
const content = `import {
|
|
552
612
|
FhirReadClient as BaseFhirReadClient,
|
|
553
613
|
FhirWriteClient as BaseFhirWriteClient,
|
|
554
614
|
type FetchFn,
|
|
615
|
+
type SearchParams,
|
|
555
616
|
} from "${clientPkg}";
|
|
556
617
|
import type * as GeneratedTypes from "../index.js";
|
|
557
|
-
|
|
618
|
+
${spImportLine}
|
|
558
619
|
/**
|
|
559
620
|
* Profile-specific FHIR Read Client.
|
|
560
621
|
* Extends the base ${slug.toUpperCase()} read client with typed profile accessors.
|
|
@@ -622,13 +683,25 @@ export class FhirClient {
|
|
|
622
683
|
/**
|
|
623
684
|
* Generate index.ts barrel export
|
|
624
685
|
*/
|
|
625
|
-
function generateIndex(clientDir, resourceTypes) {
|
|
686
|
+
function generateIndex(clientDir, resourceTypes, searchParams) {
|
|
626
687
|
const readerTypes = resourceTypes
|
|
627
688
|
.map((rt) => ` ${rt.profileName}Reader,`)
|
|
628
689
|
.join("\n");
|
|
629
690
|
const writerTypes = resourceTypes
|
|
630
691
|
.map((rt) => ` ${rt.profileName}Writer,`)
|
|
631
692
|
.join("\n");
|
|
693
|
+
// Collect per-resource SearchParams type exports
|
|
694
|
+
const spTypeExports = new Set();
|
|
695
|
+
if (searchParams && searchParams.size > 0) {
|
|
696
|
+
for (const rt of resourceTypes) {
|
|
697
|
+
const spType = getSearchParamsTypeName(rt.baseResourceType, searchParams);
|
|
698
|
+
if (spType !== 'SearchParams')
|
|
699
|
+
spTypeExports.add(spType);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const spExportLine = spTypeExports.size > 0
|
|
703
|
+
? `export type { CommonSearchParams, SearchParamValue, ${[...spTypeExports].sort().join(', ')} } from "./types.js";\n`
|
|
704
|
+
: '';
|
|
632
705
|
const content = `export { FhirClient, FhirReadClient, FhirWriteClient } from "./fhir-client.js";
|
|
633
706
|
export { FhirResourceReader } from "./resource-reader.js";
|
|
634
707
|
export type { FetchFn } from "./resource-reader.js";
|
|
@@ -644,7 +717,7 @@ ${writerTypes}
|
|
|
644
717
|
export { BundleParser, parseBundle } from "./bundle-parser.js";
|
|
645
718
|
export type { BundleEntry } from "./bundle-parser.js";
|
|
646
719
|
export type { Bundle, FhirResource, SearchParams, WithId } from "./types.js";
|
|
647
|
-
export { SmartAuth, discoverEndpoints } from "./smart-auth.js";
|
|
720
|
+
${spExportLine}export { SmartAuth, discoverEndpoints } from "./smart-auth.js";
|
|
648
721
|
export type { SmartConfig, SmartToken, SmartConfiguration, LaunchMode } from "./smart-auth.js";
|
|
649
722
|
export { SmartFhirClient } from "./smart-client.js";
|
|
650
723
|
`;
|
|
@@ -226,14 +226,14 @@ export interface Bundle<T = ${fhirNs}.Resource> {
|
|
|
226
226
|
entry?: { resource?: T; fullUrl?: string; search?: { mode?: string; score?: number }; request?: { method: string; url: string }; response?: { status: string } }[];
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
export declare class FhirResourceReader<T extends ${fhirNs}.Resource> {
|
|
229
|
+
export declare class FhirResourceReader<T extends ${fhirNs}.Resource, S extends SearchParams = SearchParams> {
|
|
230
230
|
readonly baseUrl: string;
|
|
231
231
|
readonly resourceType: string;
|
|
232
232
|
constructor(baseUrl: string, resourceType: string, fetchFn?: FetchFn);
|
|
233
233
|
read(id: string): Promise<WithId<T>>;
|
|
234
|
-
search(params?:
|
|
235
|
-
searchOne(params?:
|
|
236
|
-
searchAll(params?:
|
|
234
|
+
search(params?: S): Promise<Bundle<WithId<T>>>;
|
|
235
|
+
searchOne(params?: S): Promise<WithId<T> | undefined>;
|
|
236
|
+
searchAll(params?: S): Promise<WithId<T>[]>;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
export declare class FhirResourceWriter<T extends ${fhirNs}.Resource> {
|
|
@@ -248,7 +248,7 @@ export declare class FhirResourceWriter<T extends ${fhirNs}.Resource> {
|
|
|
248
248
|
|
|
249
249
|
export declare class FhirReadClient {
|
|
250
250
|
constructor(baseUrl: string, fetchFn?: FetchFn);
|
|
251
|
-
protected forType<T extends ${fhirNs}.Resource>(resourceType: string): FhirResourceReader<T>;
|
|
251
|
+
protected forType<T extends ${fhirNs}.Resource, S extends SearchParams = SearchParams>(resourceType: string): FhirResourceReader<T, S>;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
export declare class FhirWriteClient {
|
|
@@ -262,6 +262,13 @@ export declare class FhirClient {
|
|
|
262
262
|
read(): FhirReadClient;
|
|
263
263
|
write(): FhirWriteClient;
|
|
264
264
|
}
|
|
265
|
+
|
|
266
|
+
export declare class BundleParser {
|
|
267
|
+
static getResourcesByType<T extends ${fhirNs}.Resource>(bundle: Bundle<${fhirNs}.Resource>, resourceType: string): WithId<T>[];
|
|
268
|
+
static getFirstResourceByType<T extends ${fhirNs}.Resource>(bundle: Bundle<${fhirNs}.Resource>, resourceType: string): WithId<T> | undefined;
|
|
269
|
+
static getAllResources(bundle: Bundle<${fhirNs}.Resource>): ${fhirNs}.Resource[];
|
|
270
|
+
static resolveReference<T extends ${fhirNs}.Resource>(ref: { reference?: string } | undefined, bundle: Bundle<${fhirNs}.Resource>): T | undefined;
|
|
271
|
+
}
|
|
265
272
|
`;
|
|
266
273
|
fs.writeFileSync(path.join(pkgDir, "index.d.ts"), dts);
|
|
267
274
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build per-base-resource-type SearchParams interface declarations for generated types.ts.
|
|
3
|
+
*/
|
|
4
|
+
export function buildSearchParamInterfaces(resourceTypes, searchParams) {
|
|
5
|
+
if (!searchParams || searchParams.size === 0)
|
|
6
|
+
return '';
|
|
7
|
+
const baseTypes = [...new Set(resourceTypes.map(rt => rt.baseResourceType))];
|
|
8
|
+
const blocks = [];
|
|
9
|
+
for (const baseType of baseTypes) {
|
|
10
|
+
const params = searchParams.get(baseType);
|
|
11
|
+
if (!params?.length)
|
|
12
|
+
continue;
|
|
13
|
+
// Sort params alphabetically and deduplicate
|
|
14
|
+
const sorted = [...params].sort((a, b) => a.code.localeCompare(b.code));
|
|
15
|
+
const fields = sorted
|
|
16
|
+
.map(p => ` "${p.code}"?: SearchParamValue;`)
|
|
17
|
+
.join("\n");
|
|
18
|
+
blocks.push(`/**
|
|
19
|
+
* Typed search parameters for ${baseType} resources.
|
|
20
|
+
* Generated from SearchParameter definitions in the base FHIR spec and IG.
|
|
21
|
+
*/
|
|
22
|
+
export interface ${baseType}SearchParams extends CommonSearchParams {
|
|
23
|
+
${fields}
|
|
24
|
+
}`);
|
|
25
|
+
}
|
|
26
|
+
return blocks.join("\n\n") + "\n";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get the SearchParams interface name for a given base resource type,
|
|
30
|
+
* or fall back to SearchParams if none is generated.
|
|
31
|
+
*/
|
|
32
|
+
export function getSearchParamsTypeName(baseResourceType, searchParams) {
|
|
33
|
+
if (searchParams?.has(baseResourceType) && searchParams.get(baseResourceType).length > 0) {
|
|
34
|
+
return `${baseResourceType}SearchParams`;
|
|
35
|
+
}
|
|
36
|
+
return 'SearchParams';
|
|
37
|
+
}
|
|
@@ -535,6 +535,10 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
535
535
|
const sanitizedBaseResource = baseResource ? sanitizeIdentifier(baseResource) : undefined;
|
|
536
536
|
const isBaseAbstract = sanitizedBaseResource === 'Base';
|
|
537
537
|
const extendsClause = (sanitizedBaseResource && !isBaseAbstract && !isPrimitiveType(sanitizedBaseResource)) ? ` extends ${sanitizedBaseResource}` : '';
|
|
538
|
+
// Add resourceType literal for discriminated union support (only for actual FHIR resources, not data types)
|
|
539
|
+
if (ctx.resourceType && rules().isCoreResource(ctx.resourceType) && !deduped.some(l => /^resourceType[?:]/.test(l))) {
|
|
540
|
+
deduped.unshift(`resourceType: '${ctx.resourceType}';`);
|
|
541
|
+
}
|
|
538
542
|
debug('emit root interface', parentInterfaceName, 'extends', sanitizedBaseResource, 'fields', deduped.length);
|
|
539
543
|
interfaces.push(`export interface ${parentInterfaceName}${extendsClause} {\n${deduped.map(l => ` ${l}`).join('\n')}\n}`);
|
|
540
544
|
}
|
|
@@ -26,8 +26,16 @@ export function buildRequiredConstraints(fields, arrayFieldPaths, fieldPathMap,
|
|
|
26
26
|
let rel = relBase;
|
|
27
27
|
if (!rel)
|
|
28
28
|
return;
|
|
29
|
-
// Skip slicing-declaration fields for choice[x] when slice fields exist
|
|
30
|
-
|
|
29
|
+
// Skip slicing-declaration fields for choice[x] when slice fields exist
|
|
30
|
+
// AND the field is not required. When a profile slices value[x], the field list
|
|
31
|
+
// contains both a slicing-declaration field (no typeOptions, inherits base type
|
|
32
|
+
// like Quantity) and slice fields (with typeOptions). For optional fields, skip
|
|
33
|
+
// the declaration since the slice field handles the constraint. But for required
|
|
34
|
+
// fields (min >= 1), we MUST still emit the existence check — e.g.,
|
|
35
|
+
// medication[x] (min:1) needs "medication.exists()" to verify at least one
|
|
36
|
+
// concrete medication variant is present.
|
|
37
|
+
const fieldMin = typeof field.min === 'number' ? field.min : (field.isOptional ? 0 : 1);
|
|
38
|
+
if (relFull.includes('[x]') && !field.sliceName && !field.typeOptions && slicedChoicePaths.has(relFull) && fieldMin < 1) {
|
|
31
39
|
return;
|
|
32
40
|
}
|
|
33
41
|
// For choice types (value[x]), append the concrete type name to get the correct accessor.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
-
import { extractPackage, readStructureDefinitionsFromDir, readStructureDefinitionsFromDependencies, createPackageFromDir, readValueSetCodesWithDependencies, readValueSetsFromDir, detectFhirVersion, ensureDependenciesDownloaded } from './parser/packageParser.js';
|
|
4
|
+
import { extractPackage, readStructureDefinitionsFromDir, readStructureDefinitionsFromDependencies, createPackageFromDir, readValueSetCodesWithDependencies, readValueSetsFromDir, detectFhirVersion, ensureDependenciesDownloaded, readSearchParametersFromDir } from './parser/packageParser.js';
|
|
5
5
|
import { fetchStructureDefinitions, fetchStructureDefinition, registerLocalStructureDefinitions, clearLocalStructureDefinitions, collectValueSetBindingUrls } from './parser/sdParser.js';
|
|
6
6
|
import { ensureDirectoryExists, downloadFile } from './core/utils.js';
|
|
7
7
|
import { getFhirPackagesCacheDir } from './core/cacheConfig.js';
|
|
@@ -9,6 +9,8 @@ import { processStructureDefinition, resetFetchFailureTracking, getFetchFailureC
|
|
|
9
9
|
import { logger } from '../logger.js';
|
|
10
10
|
import { initVersionContext, ctx, versionSlug } from './fhir/versionContext.js';
|
|
11
11
|
import { DEFAULT_FHIR_VERSION } from './fhir/types.js';
|
|
12
|
+
import { FHIR_VERSIONS } from './fhir/versionRegistry.js';
|
|
13
|
+
import { ensureCorePackage } from './fhir/corePackageResolver.js';
|
|
12
14
|
import { spawn } from 'child_process';
|
|
13
15
|
import { buildProfileRegistries, expandValueSetsWithTx, emitValueSetFiles, cleanupStaleValueSetDir, initGenerationContext, enrichValueSetsFromCodeMap, collectReferencedDependencyProfiles, } from './generationHelpers.js';
|
|
14
16
|
/** Resolve the babelfhir-ts CLI version from its own package.json (used to stamp generated packages). */
|
|
@@ -23,7 +25,7 @@ const _generatorPkgPath = [
|
|
|
23
25
|
catch {
|
|
24
26
|
return false;
|
|
25
27
|
} });
|
|
26
|
-
const GENERATOR_VERSION = _generatorPkgPath ? JSON.parse(fs.readFileSync(_generatorPkgPath, 'utf8')).version : 'unknown';
|
|
28
|
+
export const GENERATOR_VERSION = _generatorPkgPath ? JSON.parse(fs.readFileSync(_generatorPkgPath, 'utf8')).version : 'unknown';
|
|
27
29
|
import { resetTimings, startPhase, getTimingSummary, formatTimingSummary } from './timing.js';
|
|
28
30
|
export { getTimingSummary, formatTimingSummary } from './timing.js';
|
|
29
31
|
const log = logger.withTag('generator');
|
|
@@ -41,6 +43,44 @@ function buildFhirChildTypeMapFromJson() {
|
|
|
41
43
|
return result;
|
|
42
44
|
}
|
|
43
45
|
export { resetFetchFailureTracking, getFetchFailureCount, getFetchFailureWarning };
|
|
46
|
+
/**
|
|
47
|
+
* Load search parameters from the FHIR core package and the IG's extracted root.
|
|
48
|
+
* Merges both sets, with IG params taking precedence for deduplication by code.
|
|
49
|
+
*/
|
|
50
|
+
async function loadSearchParameters(extractedRoot) {
|
|
51
|
+
const slug = versionSlug();
|
|
52
|
+
const coreSpec = FHIR_VERSIONS[slug]?.corePackage;
|
|
53
|
+
const merged = new Map();
|
|
54
|
+
// Load base FHIR search params from the core package
|
|
55
|
+
if (coreSpec) {
|
|
56
|
+
try {
|
|
57
|
+
const coreDir = await ensureCorePackage(coreSpec);
|
|
58
|
+
const coreParams = readSearchParametersFromDir(coreDir);
|
|
59
|
+
for (const [baseType, params] of coreParams) {
|
|
60
|
+
merged.set(baseType, [...params]);
|
|
61
|
+
}
|
|
62
|
+
log.debug(`Loaded ${coreParams.size} base resource type search param groups from core package`);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
log.warn(`Could not load core search parameters: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Load IG-specific search params (may override or extend base)
|
|
69
|
+
const igParams = readSearchParametersFromDir(extractedRoot);
|
|
70
|
+
for (const [baseType, params] of igParams) {
|
|
71
|
+
const existing = merged.get(baseType) || [];
|
|
72
|
+
for (const p of params) {
|
|
73
|
+
if (!existing.some(e => e.code === p.code)) {
|
|
74
|
+
existing.push(p);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
merged.set(baseType, existing);
|
|
78
|
+
}
|
|
79
|
+
if (igParams.size > 0) {
|
|
80
|
+
log.debug(`Loaded ${igParams.size} IG-specific search param groups`);
|
|
81
|
+
}
|
|
82
|
+
return merged;
|
|
83
|
+
}
|
|
44
84
|
/**
|
|
45
85
|
* Copy the fhir-<version>.d.ts ambient module declaration into the output directory.
|
|
46
86
|
* This is required so generated code can import from 'fhir/r4' (or r4b) even when
|
|
@@ -89,9 +129,10 @@ async function compileTypeScriptToJS(dir) {
|
|
|
89
129
|
};
|
|
90
130
|
const tsconfigPath = path.join(dir, 'tsconfig.temp.json');
|
|
91
131
|
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2));
|
|
132
|
+
// shell required on Windows for .cmd wrappers; disabled on Unix to prevent injection
|
|
92
133
|
const child = spawn(tscCmd, ['-p', tsconfigPath], {
|
|
93
134
|
stdio: 'inherit',
|
|
94
|
-
shell:
|
|
135
|
+
shell: process.platform === 'win32'
|
|
95
136
|
});
|
|
96
137
|
child.on('exit', (code) => {
|
|
97
138
|
// Clean up temp tsconfig
|
|
@@ -408,6 +449,7 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
|
|
|
408
449
|
...(originalPkg.fhirVersions && { fhirVersions: originalPkg.fhirVersions }),
|
|
409
450
|
...(flags?.txServer && { txServer: flags.txServer }),
|
|
410
451
|
...(flags?.displayLanguage && { displayLanguage: flags.displayLanguage }),
|
|
452
|
+
...(flags?.fhirVersion && { fhirVersion: flags.fhirVersion }),
|
|
411
453
|
...(flags?.dicomweb && { dicomweb: true }),
|
|
412
454
|
...(flags?.noClient && { noClient: true }),
|
|
413
455
|
...(flags?.noClasses && { noClasses: true }),
|
|
@@ -461,9 +503,12 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
|
|
|
461
503
|
// Generate FHIR client (unless --no-client flag)
|
|
462
504
|
if (!flags?.noClient) {
|
|
463
505
|
logger.log('Generating FHIR client...');
|
|
506
|
+
// Load search parameters from base FHIR spec + IG for typed search param generation
|
|
507
|
+
const searchParams = await loadSearchParameters(extractedRoot);
|
|
464
508
|
const { generateClient } = await import('./emitters/client/clientGenerator.js');
|
|
465
509
|
generateClient({
|
|
466
|
-
outputDir
|
|
510
|
+
outputDir,
|
|
511
|
+
searchParams,
|
|
467
512
|
});
|
|
468
513
|
logger.log('FHIR client generated');
|
|
469
514
|
}
|
|
@@ -653,6 +698,7 @@ export async function generateIntoPackageDirect(packageArchivePath, flags) {
|
|
|
653
698
|
...(originalPkg.fhirVersions && { fhirVersions: originalPkg.fhirVersions }),
|
|
654
699
|
...(flags?.txServer && { txServer: flags.txServer }),
|
|
655
700
|
...(flags?.displayLanguage && { displayLanguage: flags.displayLanguage }),
|
|
701
|
+
...(flags?.fhirVersion && { fhirVersion: flags.fhirVersion }),
|
|
656
702
|
...(flags?.dicomweb && { dicomweb: true }),
|
|
657
703
|
...(flags?.noClient && { noClient: true }),
|
|
658
704
|
...(flags?.noClasses && { noClasses: true }),
|
|
@@ -694,8 +740,9 @@ export async function generateIntoPackageDirect(packageArchivePath, flags) {
|
|
|
694
740
|
await generateIndexFile(outputDir);
|
|
695
741
|
if (!flags?.noClient) {
|
|
696
742
|
logger.log('Generating FHIR client...');
|
|
743
|
+
const searchParams = await loadSearchParameters(extractedRoot);
|
|
697
744
|
const { generateClient } = await import('./emitters/client/clientGenerator.js');
|
|
698
|
-
generateClient({ outputDir });
|
|
745
|
+
generateClient({ outputDir, searchParams });
|
|
699
746
|
logger.log('FHIR client generated');
|
|
700
747
|
}
|
|
701
748
|
// Generate DICOMweb helpers (--dicomweb flag)
|
|
@@ -893,9 +940,11 @@ export async function generateForDirectory(inputDir, outputDir, flags) {
|
|
|
893
940
|
// Generate FHIR client (unless --no-client flag)
|
|
894
941
|
if (!flags?.noClient) {
|
|
895
942
|
logger.log('Generating FHIR client...');
|
|
943
|
+
const searchParams = await loadSearchParameters(inputDir);
|
|
896
944
|
const { generateClient } = await import('./emitters/client/clientGenerator.js');
|
|
897
945
|
generateClient({
|
|
898
|
-
outputDir
|
|
946
|
+
outputDir,
|
|
947
|
+
searchParams,
|
|
899
948
|
});
|
|
900
949
|
logger.log('FHIR client generated');
|
|
901
950
|
}
|
|
@@ -314,7 +314,32 @@ export async function extractPackage(packagePath) {
|
|
|
314
314
|
await extract({ cwd: extractDir, file: packagePath });
|
|
315
315
|
}
|
|
316
316
|
else if (packagePath.endsWith(".zip")) {
|
|
317
|
-
|
|
317
|
+
// Use unzipper.Parse to validate each entry path against Zip Slip before extraction
|
|
318
|
+
const resolvedExtractDir = path.resolve(extractDir);
|
|
319
|
+
await new Promise((resolve, reject) => {
|
|
320
|
+
fs.createReadStream(packagePath)
|
|
321
|
+
.pipe(unzipper.Parse())
|
|
322
|
+
.on('entry', (entry) => {
|
|
323
|
+
const entryPath = path.join(extractDir, entry.path);
|
|
324
|
+
const resolvedEntry = path.resolve(entryPath);
|
|
325
|
+
// Zip Slip check: reject entries that escape the extraction directory
|
|
326
|
+
if (!resolvedEntry.startsWith(resolvedExtractDir + path.sep) && resolvedEntry !== resolvedExtractDir) {
|
|
327
|
+
logger.warn(`[security] Skipping zip entry with path traversal: ${entry.path}`);
|
|
328
|
+
entry.autodrain();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const dir = path.dirname(entryPath);
|
|
332
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
333
|
+
if (entry.type === 'Directory') {
|
|
334
|
+
entry.autodrain();
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
entry.pipe(fs.createWriteStream(entryPath));
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
.on('close', resolve)
|
|
341
|
+
.on('error', reject);
|
|
342
|
+
});
|
|
318
343
|
}
|
|
319
344
|
else {
|
|
320
345
|
throw new Error("Unsupported package format. Only .tgz and .zip are supported.");
|
|
@@ -806,3 +831,39 @@ export function readValueSetsFromDir(extractedRoot) {
|
|
|
806
831
|
}
|
|
807
832
|
return map;
|
|
808
833
|
}
|
|
834
|
+
/**
|
|
835
|
+
* Read all SearchParameter resources from a package directory.
|
|
836
|
+
* Returns a map of base resource type → array of search parameter definitions.
|
|
837
|
+
*/
|
|
838
|
+
export function readSearchParametersFromDir(extractedRoot) {
|
|
839
|
+
const jsonFiles = findJsonFilesByPrefix(extractedRoot, 'SearchParameter-');
|
|
840
|
+
const map = new Map();
|
|
841
|
+
for (const file of jsonFiles) {
|
|
842
|
+
try {
|
|
843
|
+
const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
844
|
+
if (content.resourceType !== 'SearchParameter')
|
|
845
|
+
continue;
|
|
846
|
+
if (!content.code || !content.type || !content.base?.length)
|
|
847
|
+
continue;
|
|
848
|
+
if (content.status === 'retired')
|
|
849
|
+
continue;
|
|
850
|
+
const param = {
|
|
851
|
+
code: content.code,
|
|
852
|
+
type: content.type,
|
|
853
|
+
base: content.base,
|
|
854
|
+
};
|
|
855
|
+
for (const baseType of param.base) {
|
|
856
|
+
const existing = map.get(baseType) || [];
|
|
857
|
+
// Avoid duplicates by code
|
|
858
|
+
if (!existing.some(p => p.code === param.code)) {
|
|
859
|
+
existing.push(param);
|
|
860
|
+
map.set(baseType, existing);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
catch (err) {
|
|
865
|
+
log.debug(`Skipping invalid JSON while reading SearchParameters ${file}: ${err.message}`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return map;
|
|
869
|
+
}
|
package/out/src/main.js
CHANGED
|
@@ -58,6 +58,7 @@ function printUsage() {
|
|
|
58
58
|
console.log(" --schema <format> Generate schema files alongside outputs (supported: zod)");
|
|
59
59
|
console.log(" --dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG");
|
|
60
60
|
console.log(" --recursive (update only) Recursively search subdirectories for lib/ folders");
|
|
61
|
+
console.log(" --force (update only) Force regeneration even if version and flags haven't changed");
|
|
61
62
|
console.log(" --outDir <dir> Output directory (alias for second positional argument)");
|
|
62
63
|
console.log(" --fhir-version <ver> FHIR version to target: r4, r4b, or r5 (auto-detected from package if omitted)");
|
|
63
64
|
console.log(" --package <pkg[@version]> Download FHIR package from registry and process it (latest if no version)");
|
|
@@ -234,6 +235,10 @@ export function parseCliArgs(argv) {
|
|
|
234
235
|
out.flags.recursive = true;
|
|
235
236
|
continue;
|
|
236
237
|
}
|
|
238
|
+
if (arg === '--force') {
|
|
239
|
+
out.flags.force = true;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
237
242
|
if (arg === '--cache-dir') {
|
|
238
243
|
const val = argv[i + 1];
|
|
239
244
|
if (!val || val.startsWith('-'))
|
|
@@ -377,6 +382,7 @@ export async function run() {
|
|
|
377
382
|
const result = await handleUpdateCommand({
|
|
378
383
|
packageFilter: packageSpec || positionals[1] || undefined,
|
|
379
384
|
recursive: !!generationFlags.recursive,
|
|
385
|
+
force: !!generationFlags.force,
|
|
380
386
|
generationFlags,
|
|
381
387
|
registry,
|
|
382
388
|
downloadPackage,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "babelfhir-ts",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "BabelFHIR-TS: generate TypeScript interfaces, validators, and helper classes from FHIR R4/R4B/R5 StructureDefinitions (profiles) directly inside package archives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "out/src/main.js",
|