babelfhir-ts 1.5.20 → 1.6.0
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 +6 -3
- package/out/src/cli/updateCommand.js +19 -12
- package/out/src/generator/core/extensionSdReader.js +100 -0
- package/out/src/generator/core/utils.js +126 -2
- package/out/src/generator/emitters/class/classGeneratorHelpers.js +10 -2
- package/out/src/generator/emitters/class/classTemplate.js +8 -2
- package/out/src/generator/emitters/class/sliceElementDefaults.js +10 -1
- package/out/src/generator/emitters/client/clientGenerator.js +22 -6
- package/out/src/generator/emitters/client/smartAuthGenerator.js +4 -1
- package/out/src/generator/emitters/interface/backboneSliceTyping.js +2 -2
- package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
- package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
- package/out/src/generator/emitters/interface/postProcessExtensions.js +9 -2
- package/out/src/generator/emitters/interface/processNestedField.js +4 -6
- package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
- package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
- package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +149 -0
- package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
- package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +112 -40
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +5 -1
- package/out/src/generator/emitters/validator/subExtensionScope.js +108 -0
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +13 -1
- package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +249 -24
- package/out/src/generator/emitters/validator/validatorGenerator.js +34 -41
- package/out/src/generator/emitters/validator/validatorRuntime.js +30 -9
- package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +39 -3
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +36 -8
- package/out/src/generator/generationHelpers.js +59 -7
- package/out/src/generator/index.js +111 -58
- package/out/src/generator/parser/packageParser.js +76 -0
- package/out/src/generator/parser/sdParser.js +1 -0
- package/out/src/generator/parser/vsParser.js +4 -2
- package/out/src/generator/sdProcessor.js +237 -40
- package/out/src/generator/sdProcessorRequiredFields.js +32 -7
- package/out/src/main.js +5 -0
- package/package.json +13 -9
- package/parity-matrix.json +6 -0
package/README.md
CHANGED
|
@@ -61,9 +61,9 @@ Every push to `develop` and `main` runs the parity pipeline, which validates gen
|
|
|
61
61
|
4. Generates `empty()` and `random()` test resources for every profile
|
|
62
62
|
5. Validates those resources against two independent external FHIR validators
|
|
63
63
|
|
|
64
|
-
### Validated Implementation Guides (
|
|
64
|
+
### Validated Implementation Guides (31)
|
|
65
65
|
|
|
66
|
-
Every parity run validates **
|
|
66
|
+
Every parity run validates **31 real-world IGs** (29 R4, 2 R5). The list is not duplicated here — it is generated from the parity suite's own source of truth into [`parity-matrix.json`](./parity-matrix.json).
|
|
67
67
|
|
|
68
68
|
Each validated IG is also published as a ready-to-install package — `@max-health-inc/fhir-<name>` — so you do not have to generate it yourself:
|
|
69
69
|
|
|
@@ -111,6 +111,7 @@ The `firely` job validates generated resources using the [Firely SDK Validator](
|
|
|
111
111
|

|
|
112
112
|

|
|
113
113
|

|
|
114
|
+

|
|
114
115
|
|
|
115
116
|
</details>
|
|
116
117
|
|
|
@@ -148,10 +149,11 @@ The `hl7` job validates the same artifacts using the [official HL7 FHIR Validato
|
|
|
148
149
|

|
|
149
150
|

|
|
150
151
|

|
|
152
|
+

|
|
151
153
|
|
|
152
154
|
</details>
|
|
153
155
|
|
|
154
|
-
> External-validator badges cover the
|
|
156
|
+
> External-validator badges cover the 29 R4/R4B IGs. R5 packages are generated and typechecked, but neither external validator publishes R5 badges yet.
|
|
155
157
|
|
|
156
158
|
> Terminology validation requires a tx server. The pipeline uses `--tx-server https://tx.fhir.org/{r4|r5}` (matching the package's FHIR version) during generation to expand ValueSets and produce valid codes.
|
|
157
159
|
|
|
@@ -223,6 +225,7 @@ Options:
|
|
|
223
225
|
--no-classes Only generate interfaces and types (skip class generation)
|
|
224
226
|
--no-client Skip FHIR client generation (client generated by default)
|
|
225
227
|
--schema <format> Generate schema files alongside outputs (supported: zod)
|
|
228
|
+
--zod-invariants Emit FHIRPath invariants into Zod schemas (fuller checking, slower parse)
|
|
226
229
|
--dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG
|
|
227
230
|
--prefab Generate Prefab UI render functions per profile (@maxhealth.tech/prefab)
|
|
228
231
|
--prefab-styles <path> Path to styles module (.ts/.js) copied into the generated prefab/ folder
|
|
@@ -114,21 +114,28 @@ async function promptInstallRedirect(packageFilter, confirm) {
|
|
|
114
114
|
return { redirectInstall: packageFilter };
|
|
115
115
|
return {};
|
|
116
116
|
}
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Default Y/N prompt reading from stdin.
|
|
119
|
+
*
|
|
120
|
+
* The readline import is awaited rather than `.then`-ed inside a `new Promise`:
|
|
121
|
+
* that form dropped a rejection on the floor, and since nothing ever called
|
|
122
|
+
* `resolve`, a failed import left the caller awaiting a promise that could not
|
|
123
|
+
* settle. Declining is the safe answer when the prompt cannot be shown.
|
|
124
|
+
*/
|
|
125
|
+
async function defaultConfirm(question) {
|
|
126
|
+
process.stdout.write(question);
|
|
127
|
+
const { createInterface } = await import('readline');
|
|
128
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
129
|
+
try {
|
|
130
|
+
return await new Promise((resolve) => {
|
|
131
|
+
rl.question('', (answer) => {
|
|
128
132
|
resolve(!answer || answer.toLowerCase().startsWith('y'));
|
|
129
133
|
});
|
|
130
134
|
});
|
|
131
|
-
}
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
rl.close();
|
|
138
|
+
}
|
|
132
139
|
}
|
|
133
140
|
// ── Discovery ───────────────────────────────────────────────────────────────
|
|
134
141
|
/** Find all lib/ directories starting from startDir. Non-recursive returns only startDir/lib. */
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading what an Extension StructureDefinition declares about its own contents.
|
|
3
|
+
*
|
|
4
|
+
* A complex extension carries its requirements in its own SD. A profile that
|
|
5
|
+
* references one re-declares the sub-extension paths as mustSupport markers but
|
|
6
|
+
* copies none of the constraints, so anything read only from the profile's field
|
|
7
|
+
* model misses them entirely — KBV_PR_ERP_PracticeSupply's
|
|
8
|
+
* `extension:Kostentraeger.extension:Kostentraegertyp.value[x]:valueCoding` has no
|
|
9
|
+
* binding, while KBV_EX_ERP_PracticeSupply_Payor binds it required to
|
|
10
|
+
* KBV_VS_FOR_Payor_type.
|
|
11
|
+
*/
|
|
12
|
+
import { stripVersionFromCanonicalUrl } from './utils.js';
|
|
13
|
+
/** `Extension.extension:<name>` with no further path below it. */
|
|
14
|
+
const SUB_SLICE_ID = /^Extension\.extension:([^.:]+)$/;
|
|
15
|
+
/**
|
|
16
|
+
* The elements to read constraints from.
|
|
17
|
+
*
|
|
18
|
+
* Prefer the snapshot, but fall back to the differential: several published
|
|
19
|
+
* extension SDs ship without one (KBV_EX_ERP_PracticeSupply_Payor among them), and
|
|
20
|
+
* requiring a snapshot meant they resolved to no metadata at all.
|
|
21
|
+
*/
|
|
22
|
+
export function extensionElements(sd) {
|
|
23
|
+
const els = sd?.snapshot?.element ?? sd?.differential?.element;
|
|
24
|
+
return els?.length ? els : undefined;
|
|
25
|
+
}
|
|
26
|
+
/** Read a binding off an element, normalising the ValueSet reference to a bare uri. */
|
|
27
|
+
export function readBinding(el) {
|
|
28
|
+
const binding = el?.binding;
|
|
29
|
+
if (!binding?.strength || !binding.valueSet)
|
|
30
|
+
return undefined;
|
|
31
|
+
const raw = typeof binding.valueSet === 'string'
|
|
32
|
+
? binding.valueSet
|
|
33
|
+
: binding.valueSet?.reference;
|
|
34
|
+
if (!raw)
|
|
35
|
+
return undefined;
|
|
36
|
+
return { strength: binding.strength, uri: stripVersionFromCanonicalUrl(raw) };
|
|
37
|
+
}
|
|
38
|
+
/** Type codes declared on an element. */
|
|
39
|
+
function typeCodes(el) {
|
|
40
|
+
return (el?.type ?? []).map(t => t.code).filter((c) => !!c);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `min` on the unsliced `Extension.extension` element.
|
|
44
|
+
*
|
|
45
|
+
* This is what HL7 reports as "Extension.extension: minimum required = N", and it is
|
|
46
|
+
* not the sum of the slice minimums — the SD states it directly.
|
|
47
|
+
*/
|
|
48
|
+
export function readSubExtensionMin(els) {
|
|
49
|
+
const el = els.find(e => (e.id ?? e.path) === 'Extension.extension' && !e.sliceName);
|
|
50
|
+
return typeof el?.min === 'number' ? el.min : undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Collect the sub-extension slices a complex extension declares.
|
|
54
|
+
*
|
|
55
|
+
* A slice with no resolvable `url` is dropped rather than reported under its slice
|
|
56
|
+
* name: the url is how a member is recognised at runtime, and without it any check
|
|
57
|
+
* would be guessing which member to apply itself to.
|
|
58
|
+
*/
|
|
59
|
+
export function collectSubExtensionSlices(els) {
|
|
60
|
+
const byId = new Map();
|
|
61
|
+
for (const el of els) {
|
|
62
|
+
const id = el.id ?? el.path;
|
|
63
|
+
if (id)
|
|
64
|
+
byId.set(id, el);
|
|
65
|
+
}
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const el of els) {
|
|
68
|
+
const id = el.id ?? el.path;
|
|
69
|
+
const match = id ? SUB_SLICE_ID.exec(id) : null;
|
|
70
|
+
if (!match)
|
|
71
|
+
continue;
|
|
72
|
+
const sliceName = el.sliceName ?? match[1];
|
|
73
|
+
const urlEl = byId.get(`Extension.extension:${sliceName}.url`);
|
|
74
|
+
const url = typeof urlEl?.fixedUri === 'string' ? urlEl.fixedUri : undefined;
|
|
75
|
+
if (!url)
|
|
76
|
+
continue;
|
|
77
|
+
const valueEl = byId.get(`Extension.extension:${sliceName}.value[x]`);
|
|
78
|
+
let valueTypes = typeCodes(valueEl);
|
|
79
|
+
let binding = readBinding(valueEl);
|
|
80
|
+
// The binding usually sits on the typed slice (…value[x]:valueCoding) rather than
|
|
81
|
+
// on value[x] itself, which is where the Payor extension puts it.
|
|
82
|
+
for (const code of valueTypes.length ? valueTypes : ['']) {
|
|
83
|
+
const suffix = code ? code.charAt(0).toUpperCase() + code.slice(1) : '';
|
|
84
|
+
const typedEl = suffix ? byId.get(`Extension.extension:${sliceName}.value[x]:value${suffix}`) : undefined;
|
|
85
|
+
if (!typedEl)
|
|
86
|
+
continue;
|
|
87
|
+
binding = binding ?? readBinding(typedEl);
|
|
88
|
+
if (valueTypes.length === 0)
|
|
89
|
+
valueTypes = typeCodes(typedEl);
|
|
90
|
+
}
|
|
91
|
+
out.push({
|
|
92
|
+
sliceName,
|
|
93
|
+
url,
|
|
94
|
+
min: typeof el.min === 'number' ? el.min : 0,
|
|
95
|
+
valueTypes,
|
|
96
|
+
binding,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
@@ -14,6 +14,33 @@ export function stripVersionFromCanonicalUrl(url) {
|
|
|
14
14
|
const pipeIndex = url.indexOf('|');
|
|
15
15
|
return pipeIndex >= 0 ? url.substring(0, pipeIndex) : url;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* An element path with any slice suffix removed: `identifier:NPI` -> `identifier`.
|
|
19
|
+
*
|
|
20
|
+
* FHIR element ids name the slice after a colon, and most path comparisons want
|
|
21
|
+
* the unsliced form. Written with indexOf rather than `split(':')[0]` so it
|
|
22
|
+
* returns a string: the split form is an indexing expression, which is only
|
|
23
|
+
* `string | undefined` to the compiler even though it can never be empty.
|
|
24
|
+
*/
|
|
25
|
+
export function stripSliceSuffix(path) {
|
|
26
|
+
if (!path)
|
|
27
|
+
return path;
|
|
28
|
+
const colonIndex = path.indexOf(':');
|
|
29
|
+
return colonIndex >= 0 ? path.substring(0, colonIndex) : path;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The first dot-separated segment of a path: `code.coding.system` -> `code`.
|
|
33
|
+
*
|
|
34
|
+
* indexOf rather than `split('.')[0]`, for the same reason as
|
|
35
|
+
* {@link stripSliceSuffix}: the split form is an indexing expression and reads as
|
|
36
|
+
* possibly-undefined even when it cannot be.
|
|
37
|
+
*/
|
|
38
|
+
export function firstSegment(path) {
|
|
39
|
+
if (!path)
|
|
40
|
+
return path;
|
|
41
|
+
const dotIndex = path.indexOf('.');
|
|
42
|
+
return dotIndex >= 0 ? path.substring(0, dotIndex) : path;
|
|
43
|
+
}
|
|
17
44
|
export function writeInterfaceAndValidatorToFile(outputDir, interfaceName, interfaceContent, validateFunctionContent) {
|
|
18
45
|
const filePath = path.join(outputDir, `${interfaceName}.ts`);
|
|
19
46
|
const content = `${interfaceContent}\n\n${validateFunctionContent}`;
|
|
@@ -181,6 +208,48 @@ export function alignFields(newFields, oldFields) {
|
|
|
181
208
|
// Return the original oldFields and the aligned newFields
|
|
182
209
|
return { alignedNewFields, oldFields };
|
|
183
210
|
}
|
|
211
|
+
/** A type the parser could not pin down from the element itself. */
|
|
212
|
+
function isUnstatedType(type) {
|
|
213
|
+
return !type || type === 'any';
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Carry forward what a differential element left unstated.
|
|
217
|
+
*
|
|
218
|
+
* A differential says only what it changes, so an element that constrains
|
|
219
|
+
* cardinality alone carries no `type` and an element that constrains `min` alone
|
|
220
|
+
* carries no `max`. Both are inherited rather than defaulted away.
|
|
221
|
+
*/
|
|
222
|
+
function inheritUnstatedFromBase(field, base) {
|
|
223
|
+
if (!base)
|
|
224
|
+
return field;
|
|
225
|
+
const merged = { ...field };
|
|
226
|
+
if (isUnstatedType(merged.type) && !isUnstatedType(base.type)) {
|
|
227
|
+
merged.type = base.type;
|
|
228
|
+
}
|
|
229
|
+
if (!merged.baseTypeCode && base.baseTypeCode) {
|
|
230
|
+
merged.baseTypeCode = base.baseTypeCode;
|
|
231
|
+
}
|
|
232
|
+
// `max` absent means the differential did not narrow the upper bound, so the
|
|
233
|
+
// base's bound — and with it whether the element repeats — still holds.
|
|
234
|
+
if (merged.max === undefined && base.max !== undefined) {
|
|
235
|
+
merged.max = base.max;
|
|
236
|
+
merged.isArray = base.isArray;
|
|
237
|
+
}
|
|
238
|
+
if (!merged.typeOptions?.length && base.typeOptions?.length) {
|
|
239
|
+
merged.typeOptions = base.typeOptions;
|
|
240
|
+
}
|
|
241
|
+
// An extension slice is recognised at runtime by the canonical its profile
|
|
242
|
+
// names. A derived profile that only tightens cardinality does not restate it —
|
|
243
|
+
// KBV_PR_ERP_Medication_PZN redeclares extension:Kategorie and extension:Normgroesse
|
|
244
|
+
// with no type.profile, while KBV_PR_Base_Medication gives them
|
|
245
|
+
// KBV_EX_Base_Medication_Type and .../normgroesse. Dropping the base's left two
|
|
246
|
+
// of four slices unidentifiable, so the closed-slicing check on
|
|
247
|
+
// Medication.extension could not build its allowed set and stayed silent.
|
|
248
|
+
if (!merged.profileUrls?.length && base.profileUrls?.length) {
|
|
249
|
+
merged.profileUrls = base.profileUrls;
|
|
250
|
+
}
|
|
251
|
+
return merged;
|
|
252
|
+
}
|
|
184
253
|
export function mergeFields(newFields, oldFields) {
|
|
185
254
|
const fieldMap = new Map();
|
|
186
255
|
// Create unique key for field - use elementId if available (includes slice names for child elements)
|
|
@@ -208,9 +277,22 @@ export function mergeFields(newFields, oldFields) {
|
|
|
208
277
|
}
|
|
209
278
|
fieldMap.set(key, field);
|
|
210
279
|
}
|
|
211
|
-
// Overwrite or add newFields to the map
|
|
280
|
+
// Overwrite or add newFields to the map.
|
|
281
|
+
//
|
|
282
|
+
// A differential element states only what it *changes*. Replacing the base
|
|
283
|
+
// field wholesale therefore threw away everything it stayed silent about:
|
|
284
|
+
// KBV constrains Medication.amount.numerator.value to max=0 and says nothing
|
|
285
|
+
// about its type, so the merged field read `any` and every type-aware check
|
|
286
|
+
// skipped it — HL7 reported "The value 'Example value' is not a valid decimal"
|
|
287
|
+
// where we said nothing. The same silence on `max` left Medication.ingredient
|
|
288
|
+
// with isArray=false despite being 0..* in the base, so paths beneath it were
|
|
289
|
+
// never walked.
|
|
290
|
+
//
|
|
291
|
+
// Only genuinely absent properties are filled in; anything the differential
|
|
292
|
+
// states still wins.
|
|
212
293
|
for (const field of newFields) {
|
|
213
|
-
|
|
294
|
+
const key = getFieldKey(field);
|
|
295
|
+
fieldMap.set(key, inheritUnstatedFromBase(field, fieldMap.get(key)));
|
|
214
296
|
}
|
|
215
297
|
// Return the merged fields as an array
|
|
216
298
|
return Array.from(fieldMap.values());
|
|
@@ -276,6 +358,48 @@ export function sanitizeIdentifier(name) {
|
|
|
276
358
|
cleaned = `_${cleaned}`;
|
|
277
359
|
return cleaned;
|
|
278
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Turn a FHIR resource name into a PascalCase TypeScript identifier.
|
|
363
|
+
*
|
|
364
|
+
* Shared by the ValueSet and NamingSystem emitters, which previously carried
|
|
365
|
+
* the same split-and-capitalize loop with only the digit prefix differing.
|
|
366
|
+
* {@link toPascalCase} alone is not enough: it drops the underscore that
|
|
367
|
+
* {@link sanitizeIdentifier} adds, so a name starting with a digit comes back
|
|
368
|
+
* as an invalid identifier.
|
|
369
|
+
*/
|
|
370
|
+
export function pascalCaseIdentifier(name, opts) {
|
|
371
|
+
const stripped = opts.stripPrefix ? name.replace(opts.stripPrefix, '') : name;
|
|
372
|
+
const pascal = toPascalCase(stripped);
|
|
373
|
+
if (!pascal)
|
|
374
|
+
return opts.fallback;
|
|
375
|
+
return /^[0-9]/.test(pascal) ? `${opts.digitPrefix}${pascal}` : pascal;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Lower-case a name's first character, keeping a leading acronym run intact.
|
|
379
|
+
*
|
|
380
|
+
* "CHCorePatient" → "chCorePatient" (the acronym lowers wholesale, but the
|
|
381
|
+
* last capital starts the next word)
|
|
382
|
+
* "Patient" → "patient"
|
|
383
|
+
* "HumanName" → "humanName"
|
|
384
|
+
*
|
|
385
|
+
* Shared by the prefab renderer and the MCP view registry, which each carried a
|
|
386
|
+
* byte-identical copy — the second one commented "Mirrors the same logic in
|
|
387
|
+
* prefabRenderer.ts", which is the drift warning rather than a defence.
|
|
388
|
+
*
|
|
389
|
+
* charAt() is deliberate over indexing: it yields '' past the end, so the
|
|
390
|
+
* acronym run terminates at the boundary without an index assertion.
|
|
391
|
+
*/
|
|
392
|
+
export function lowerFirstPreservingAcronym(name) {
|
|
393
|
+
if (!name)
|
|
394
|
+
return name;
|
|
395
|
+
let i = 0;
|
|
396
|
+
while (i < name.length && name.charAt(i) >= 'A' && name.charAt(i) <= 'Z')
|
|
397
|
+
i++;
|
|
398
|
+
if (i <= 1)
|
|
399
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
400
|
+
const acronymEnd = i === name.length ? i : i - 1;
|
|
401
|
+
return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
|
|
402
|
+
}
|
|
279
403
|
export function toPascalCase(name) {
|
|
280
404
|
if (!name)
|
|
281
405
|
return name;
|
|
@@ -95,8 +95,16 @@ export function generatePatternMetadata(fieldPatterns, forbiddenFields, valueSet
|
|
|
95
95
|
}
|
|
96
96
|
const hasBindings = Object.keys(valueSetBindings).length > 0;
|
|
97
97
|
return `
|
|
98
|
-
/**
|
|
99
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Pattern constraints this profile fixes on its fields, keyed by field path
|
|
100
|
+
* (e.g. \`"shlSession.system": "https://example.org/fhir/shl-session"\`).
|
|
101
|
+
*
|
|
102
|
+
* Public alongside \`valueSetBindings\`: a consumer that needs a fixed value —
|
|
103
|
+
* an identifier system, a fixed code — would otherwise hand-write the literal
|
|
104
|
+
* the profile already declares, which is the duplication this package exists
|
|
105
|
+
* to remove. Read by the random() generator too.
|
|
106
|
+
*/
|
|
107
|
+
static readonly _fieldPatterns = ${JSON.stringify(patternMap, null, 2)};
|
|
100
108
|
|
|
101
109
|
/** Fields that are forbidden (max=0) in this profile - must not be generated */
|
|
102
110
|
protected static readonly _forbiddenFields: string[] = ${JSON.stringify(forbiddenFields)};
|
|
@@ -26,7 +26,7 @@ export function renderClassTemplate(params) {
|
|
|
26
26
|
const helperImportList = Array.from(helperImports).join(', ');
|
|
27
27
|
const hasVsBindings = Object.keys(valueSetBindings).length > 0;
|
|
28
28
|
return `
|
|
29
|
-
import { validate${interfaceName}, ${interfaceName} } from "./${interfaceName}.js";
|
|
29
|
+
import { validate${interfaceName}, type ${interfaceName} } from "./${interfaceName}.js";
|
|
30
30
|
import type { ValidatorOptions } from "./ValidatorOptions.js";
|
|
31
31
|
// Only import helpers actually referenced below (dynamic based on required fields)
|
|
32
32
|
import { ${helperImportList} } from "./random/index.js";
|
|
@@ -55,7 +55,13 @@ function safeClone<T>(value: T): T {
|
|
|
55
55
|
|
|
56
56
|
export class ${className} {
|
|
57
57
|
${generatePatternMetadata(fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder, codeableConceptBindings)}
|
|
58
|
-
|
|
58
|
+
private resource: ${interfaceName};
|
|
59
|
+
|
|
60
|
+
// Declared and assigned rather than written as a parameter property, which
|
|
61
|
+
// erasableSyntaxOnly bans: it emits real assignment code from what reads as a
|
|
62
|
+
// type annotation. Generated output is held to the same strictness as src/.
|
|
63
|
+
constructor(resource: ${interfaceName}) {
|
|
64
|
+
this.resource = resource;
|
|
59
65
|
${constructorMetaProfile}
|
|
60
66
|
}
|
|
61
67
|
/** Validate current resource instance. */
|
|
@@ -252,6 +252,13 @@ export function buildNestedRequirementsObject(nestedRequirements) {
|
|
|
252
252
|
// Navigate/create nested objects for all but the last part
|
|
253
253
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
254
254
|
const part = parts[i];
|
|
255
|
+
// The loop bound guarantees a segment here; if one is ever missing the
|
|
256
|
+
// requirement is abandoned rather than navigated halfway, which is the same
|
|
257
|
+
// course the non-object branch below takes.
|
|
258
|
+
if (part === undefined) {
|
|
259
|
+
skipThis = true;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
255
262
|
const isArraySegment = allArraySegments.has(parts.slice(0, i + 1).join('.'));
|
|
256
263
|
if (!(part in current)) {
|
|
257
264
|
// Create either an array with one object or a plain object
|
|
@@ -290,7 +297,9 @@ export function buildNestedRequirementsObject(nestedRequirements) {
|
|
|
290
297
|
continue;
|
|
291
298
|
}
|
|
292
299
|
// Set the final value
|
|
293
|
-
const lastPart = parts
|
|
300
|
+
const lastPart = parts.at(-1);
|
|
301
|
+
if (lastPart === undefined)
|
|
302
|
+
continue;
|
|
294
303
|
// If this property already exists and is an object with properties, merge instead of overwrite
|
|
295
304
|
const existingValue = current[lastPart];
|
|
296
305
|
const hasExplicitConstraint = req.patternConstraint !== undefined || req.fixedValue !== undefined;
|
|
@@ -176,11 +176,16 @@ export type FetchFn = typeof globalThis.fetch;
|
|
|
176
176
|
export class FhirResourceReader<T, S extends SearchParams = SearchParams> implements FhirResourceSearcher<T, S> {
|
|
177
177
|
private readonly fetchFn: FetchFn;
|
|
178
178
|
|
|
179
|
+
public readonly baseUrl: string;
|
|
180
|
+
public readonly resourceType: string;
|
|
181
|
+
|
|
179
182
|
constructor(
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
baseUrl: string,
|
|
184
|
+
resourceType: string,
|
|
182
185
|
fetchFn?: FetchFn,
|
|
183
186
|
) {
|
|
187
|
+
this.baseUrl = baseUrl;
|
|
188
|
+
this.resourceType = resourceType;
|
|
184
189
|
this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
185
190
|
}
|
|
186
191
|
|
|
@@ -321,11 +326,16 @@ import type { FetchFn } from "./resource-reader.js";
|
|
|
321
326
|
export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
|
|
322
327
|
private readonly fetchFn: FetchFn;
|
|
323
328
|
|
|
329
|
+
public readonly baseUrl: string;
|
|
330
|
+
public readonly resourceType: string;
|
|
331
|
+
|
|
324
332
|
constructor(
|
|
325
|
-
|
|
326
|
-
|
|
333
|
+
baseUrl: string,
|
|
334
|
+
resourceType: string,
|
|
327
335
|
fetchFn?: FetchFn,
|
|
328
336
|
) {
|
|
337
|
+
this.baseUrl = baseUrl;
|
|
338
|
+
this.resourceType = resourceType;
|
|
329
339
|
this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
330
340
|
}
|
|
331
341
|
|
|
@@ -451,7 +461,11 @@ export type BundleEntry<T extends FhirResource = FhirResource> = {
|
|
|
451
461
|
* FHIR Bundle Parser - provides type-safe parsing and filtering of FHIR Bundles
|
|
452
462
|
*/
|
|
453
463
|
export class BundleParser {
|
|
454
|
-
|
|
464
|
+
private readonly bundle: Bundle<FhirResource>;
|
|
465
|
+
|
|
466
|
+
constructor(bundle: Bundle<FhirResource>) {
|
|
467
|
+
this.bundle = bundle;
|
|
468
|
+
}
|
|
455
469
|
|
|
456
470
|
/**
|
|
457
471
|
* Get all resources from the bundle
|
|
@@ -659,11 +673,13 @@ ${writerMethods}
|
|
|
659
673
|
export class FhirClient {
|
|
660
674
|
private readonly readClient: FhirReadClient;
|
|
661
675
|
private readonly writeClient: FhirWriteClient;
|
|
676
|
+
public readonly baseUrl: string;
|
|
662
677
|
|
|
663
678
|
constructor(
|
|
664
|
-
|
|
679
|
+
baseUrl: string,
|
|
665
680
|
fetchFn?: FetchFn,
|
|
666
681
|
) {
|
|
682
|
+
this.baseUrl = baseUrl;
|
|
667
683
|
this.readClient = new FhirReadClient(baseUrl, fetchFn);
|
|
668
684
|
this.writeClient = new FhirWriteClient(baseUrl, fetchFn);
|
|
669
685
|
}
|
|
@@ -167,7 +167,10 @@ export class SmartAuth {
|
|
|
167
167
|
private readonly storage: Storage;
|
|
168
168
|
private readonly keys: ReturnType<typeof storageKeys>;
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
public readonly config: SmartConfig;
|
|
171
|
+
|
|
172
|
+
constructor(config: SmartConfig) {
|
|
173
|
+
this.config = config;
|
|
171
174
|
this.storage = config.storage ?? sessionStorage;
|
|
172
175
|
this.keys = storageKeys(config.storagePrefix ?? "smart_");
|
|
173
176
|
}
|
|
@@ -135,7 +135,7 @@ function collectSliceGroups(fields, baseFields, baseResource) {
|
|
|
135
135
|
if (!isEligibleBackbonePath(field.baseTypeCode || field.type || '', path, baseResource))
|
|
136
136
|
continue;
|
|
137
137
|
const childFields = gatherChildFields(fields, path);
|
|
138
|
-
const slicingRules = field.slicingRules
|
|
138
|
+
const slicingRules = field.slicingRules ?? 'open';
|
|
139
139
|
seenPaths.add(path);
|
|
140
140
|
groups.push({ parentField: field, sliceFields, childFields, slicingRules });
|
|
141
141
|
}
|
|
@@ -160,7 +160,7 @@ function collectSliceGroups(fields, baseFields, baseResource) {
|
|
|
160
160
|
if (sliceFields.length === 0)
|
|
161
161
|
continue;
|
|
162
162
|
const childFields = gatherChildFields(fields, path);
|
|
163
|
-
const slicingRules = parentField.slicingRules
|
|
163
|
+
const slicingRules = parentField.slicingRules ?? 'open';
|
|
164
164
|
seenPaths.add(path);
|
|
165
165
|
groups.push({ parentField, sliceFields, childFields, slicingRules });
|
|
166
166
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
1
|
+
import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl, stripSliceSuffix } from '../../core/utils.js';
|
|
2
2
|
import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
|
|
3
3
|
import { logger } from '../../../logger.js';
|
|
4
4
|
import { getRules } from '../../fhir/versionContext.js';
|
|
@@ -45,8 +45,8 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
45
45
|
}
|
|
46
46
|
// Register external profiles for FHIR datatype profiles (like SimpleQuantity) early
|
|
47
47
|
// This must happen before any returns to ensure the profile is registered
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const profileUrl = field.isProfiled ? field.profileUrls?.[0] : undefined;
|
|
49
|
+
if (profileUrl) {
|
|
50
50
|
// Use profileUrlToName to get the actual interface name, fallback to URL-derived name
|
|
51
51
|
// Strip version suffix from canonical URL (e.g., |5.3.0-ballot-tc1) before extracting name
|
|
52
52
|
const strippedUrl = stripVersionFromCanonicalUrl(profileUrl);
|
|
@@ -61,13 +61,9 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
const fieldParts = field.name.split(".");
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
const colonIndex = fieldName.indexOf(':');
|
|
68
|
-
if (colonIndex !== -1) {
|
|
69
|
-
fieldName = fieldName.substring(0, colonIndex);
|
|
70
|
-
}
|
|
64
|
+
// Slice notation is dropped for the property name ("extension:Stadtteil"
|
|
65
|
+
// -> "extension"); field.sliceName keeps it for semantic purposes.
|
|
66
|
+
let fieldName = stripSliceSuffix(fieldParts.at(-1) ?? field.name);
|
|
71
67
|
const isRootInterface = parentInterfaceName === interfaceName;
|
|
72
68
|
const isNested = isRootInterface ? fieldParts.length > 1 : fieldParts.length >= 1;
|
|
73
69
|
// When generating a slice-specific interface, only include direct children
|
|
@@ -126,7 +122,7 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
126
122
|
// (2 segments: Identifier.system) and skip nested paths (4+ segments: Identifier.assigner.identifier.system)
|
|
127
123
|
if (isNested && fieldParts.length >= 2) {
|
|
128
124
|
const baseTypeName = fieldParts[0]; // e.g., "Identifier" from "Identifier.assigner.identifier.system"
|
|
129
|
-
const isExtendingBaseType = baseResource && baseTypeName
|
|
125
|
+
const isExtendingBaseType = !!baseResource && baseTypeName?.toLowerCase() === baseResource.toLowerCase();
|
|
130
126
|
// Check if this field will create a nested interface (has children in fields)
|
|
131
127
|
const hasChildren = fields.some((f) => f.name.startsWith(field.name + '.'));
|
|
132
128
|
const shouldKeepForNested = field.mustSupport && hasChildren;
|
|
@@ -173,7 +169,8 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
173
169
|
}
|
|
174
170
|
else if (!isLogicalModel) {
|
|
175
171
|
// For other profiles, use the original logic
|
|
176
|
-
|
|
172
|
+
const parentSegment = fieldParts.at(-2);
|
|
173
|
+
if (!parentSegment || !parentInterfaceName.toLowerCase().includes(parentSegment.toLowerCase())) {
|
|
177
174
|
return; // Skip nested fields that don't match the parent interface name
|
|
178
175
|
}
|
|
179
176
|
}
|
|
@@ -529,7 +526,13 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
529
526
|
deduped.push(line); // JSDoc or other non-field lines
|
|
530
527
|
continue;
|
|
531
528
|
}
|
|
529
|
+
// Group 1 is present whenever the regex matched, but the type does not
|
|
530
|
+
// say so; a line without it is passed through rather than deduped.
|
|
532
531
|
const name = fieldMatch[1];
|
|
532
|
+
if (!name) {
|
|
533
|
+
deduped.push(line);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
533
536
|
if (seenFieldNames.has(name)) {
|
|
534
537
|
continue; // Skip duplicate — keep the first (more specific) version
|
|
535
538
|
}
|
|
@@ -26,11 +26,14 @@ export function canonicalToTypeName(name, profileUrlToName) {
|
|
|
26
26
|
matches.add(generatedName);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
if (matches.size === 1)
|
|
30
|
-
|
|
29
|
+
if (matches.size === 1) {
|
|
30
|
+
const [only] = [...matches];
|
|
31
|
+
if (only)
|
|
32
|
+
return only;
|
|
33
|
+
}
|
|
31
34
|
}
|
|
32
35
|
// if already a valid identifier with capitalization, return as-is
|
|
33
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) &&
|
|
36
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && /^[A-Z]/.test(t))
|
|
34
37
|
return t;
|
|
35
38
|
const parts = t.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
36
39
|
let pascal = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { capitalize, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
6
6
|
import { logger } from '../../../logger.js';
|
|
7
|
+
import {} from './importManager.js';
|
|
7
8
|
import { getRules } from '../../fhir/versionContext.js';
|
|
8
9
|
export function postProcessExtensions(params) {
|
|
9
10
|
const { interfaces, interfaceName, baseResource, newFields, baseFieldByLastSegment, importManager, existingProfiles, profileUrlToName, generatedAliasTypes, findInterfaceIndex, inferBackboneType, addTypeImport } = params;
|
|
@@ -299,9 +300,15 @@ export function postProcessExtensions(params) {
|
|
|
299
300
|
});
|
|
300
301
|
const baseFld2 = baseFieldByLastSegment.get(seg2);
|
|
301
302
|
let extendT2 = actualField2?.type || baseFld2?.type;
|
|
302
|
-
//
|
|
303
|
+
// A generic BackboneElement carries none of the base backbone's own properties, so
|
|
304
|
+
// resolve it against the level-1 backbone already resolved above
|
|
305
|
+
// (QuestionnaireResponseItem + 'answer' -> QuestionnaireResponseItemAnswer).
|
|
303
306
|
if (!extendT2 || extendT2 === 'any' || extendT2 === 'BackboneElement') {
|
|
304
|
-
|
|
307
|
+
const inferred2 = extendT1 ? inferBackboneType(extendT1, seg2) : undefined;
|
|
308
|
+
if (inferred2) {
|
|
309
|
+
extendT2 = inferred2;
|
|
310
|
+
}
|
|
311
|
+
else if (baseResource === 'Encounter' && seg1 === 'hospitalization' && seg2 === 'dischargeDisposition') {
|
|
305
312
|
extendT2 = 'CodeableConcept';
|
|
306
313
|
}
|
|
307
314
|
}
|
|
@@ -175,12 +175,10 @@ export function handleNestedField(args) {
|
|
|
175
175
|
}
|
|
176
176
|
else {
|
|
177
177
|
// No nested interface existed; create a minimal one extending the base/inferred type to host the extension union
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
parentT = inferred;
|
|
183
|
-
}
|
|
178
|
+
// Reuse the type resolved above rather than recomputing it: the local
|
|
179
|
+
// recomputation used to stop at a generic BackboneElement, which drops the
|
|
180
|
+
// base backbone's own properties (e.g. QuestionnaireResponseItem.linkId).
|
|
181
|
+
let parentT = baseFieldType;
|
|
184
182
|
if (parentT && !isPrimitiveType(parentT)) {
|
|
185
183
|
parentT = canonicalToTypeName(parentT);
|
|
186
184
|
const sanitized = sanitizeIdentifier(parentT);
|