babelfhir-ts 1.5.20 → 1.5.21
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/out/src/cli/updateCommand.js +19 -12
- package/out/src/generator/core/extensionSdReader.js +99 -0
- package/out/src/generator/core/utils.js +57 -2
- package/out/src/generator/emitters/class/classTemplate.js +8 -2
- 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/postProcessExtensions.js +1 -0
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +146 -0
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +98 -14
- package/out/src/generator/emitters/validator/subExtensionScope.js +100 -0
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +13 -1
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +218 -10
- package/out/src/generator/emitters/validator/validatorGenerator.js +4 -2
- package/out/src/generator/emitters/validator/validatorRuntime.js +17 -7
- package/out/src/generator/index.js +21 -11
- package/out/src/generator/parser/sdParser.js +1 -0
- package/out/src/generator/sdProcessor.js +209 -21
- package/out/src/generator/sdProcessorRequiredFields.js +32 -7
- package/package.json +2 -1
|
@@ -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,99 @@
|
|
|
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
|
+
/** `Extension.extension:<name>` with no further path below it. */
|
|
13
|
+
const SUB_SLICE_ID = /^Extension\.extension:([^.:]+)$/;
|
|
14
|
+
/**
|
|
15
|
+
* The elements to read constraints from.
|
|
16
|
+
*
|
|
17
|
+
* Prefer the snapshot, but fall back to the differential: several published
|
|
18
|
+
* extension SDs ship without one (KBV_EX_ERP_PracticeSupply_Payor among them), and
|
|
19
|
+
* requiring a snapshot meant they resolved to no metadata at all.
|
|
20
|
+
*/
|
|
21
|
+
export function extensionElements(sd) {
|
|
22
|
+
const els = sd?.snapshot?.element ?? sd?.differential?.element;
|
|
23
|
+
return els?.length ? els : undefined;
|
|
24
|
+
}
|
|
25
|
+
/** Read a binding off an element, normalising the ValueSet reference to a bare uri. */
|
|
26
|
+
export function readBinding(el) {
|
|
27
|
+
const binding = el?.binding;
|
|
28
|
+
if (!binding?.strength || !binding.valueSet)
|
|
29
|
+
return undefined;
|
|
30
|
+
const raw = typeof binding.valueSet === 'string'
|
|
31
|
+
? binding.valueSet
|
|
32
|
+
: binding.valueSet?.reference;
|
|
33
|
+
if (!raw)
|
|
34
|
+
return undefined;
|
|
35
|
+
return { strength: binding.strength, uri: raw.split('|')[0] };
|
|
36
|
+
}
|
|
37
|
+
/** Type codes declared on an element. */
|
|
38
|
+
function typeCodes(el) {
|
|
39
|
+
return (el?.type ?? []).map(t => t.code).filter((c) => !!c);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `min` on the unsliced `Extension.extension` element.
|
|
43
|
+
*
|
|
44
|
+
* This is what HL7 reports as "Extension.extension: minimum required = N", and it is
|
|
45
|
+
* not the sum of the slice minimums — the SD states it directly.
|
|
46
|
+
*/
|
|
47
|
+
export function readSubExtensionMin(els) {
|
|
48
|
+
const el = els.find(e => (e.id ?? e.path) === 'Extension.extension' && !e.sliceName);
|
|
49
|
+
return typeof el?.min === 'number' ? el.min : undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Collect the sub-extension slices a complex extension declares.
|
|
53
|
+
*
|
|
54
|
+
* A slice with no resolvable `url` is dropped rather than reported under its slice
|
|
55
|
+
* name: the url is how a member is recognised at runtime, and without it any check
|
|
56
|
+
* would be guessing which member to apply itself to.
|
|
57
|
+
*/
|
|
58
|
+
export function collectSubExtensionSlices(els) {
|
|
59
|
+
const byId = new Map();
|
|
60
|
+
for (const el of els) {
|
|
61
|
+
const id = el.id ?? el.path;
|
|
62
|
+
if (id)
|
|
63
|
+
byId.set(id, el);
|
|
64
|
+
}
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const el of els) {
|
|
67
|
+
const id = el.id ?? el.path;
|
|
68
|
+
const match = id ? SUB_SLICE_ID.exec(id) : null;
|
|
69
|
+
if (!match)
|
|
70
|
+
continue;
|
|
71
|
+
const sliceName = el.sliceName ?? match[1];
|
|
72
|
+
const urlEl = byId.get(`Extension.extension:${sliceName}.url`);
|
|
73
|
+
const url = typeof urlEl?.fixedUri === 'string' ? urlEl.fixedUri : undefined;
|
|
74
|
+
if (!url)
|
|
75
|
+
continue;
|
|
76
|
+
const valueEl = byId.get(`Extension.extension:${sliceName}.value[x]`);
|
|
77
|
+
let valueTypes = typeCodes(valueEl);
|
|
78
|
+
let binding = readBinding(valueEl);
|
|
79
|
+
// The binding usually sits on the typed slice (…value[x]:valueCoding) rather than
|
|
80
|
+
// on value[x] itself, which is where the Payor extension puts it.
|
|
81
|
+
for (const code of valueTypes.length ? valueTypes : ['']) {
|
|
82
|
+
const suffix = code ? code.charAt(0).toUpperCase() + code.slice(1) : '';
|
|
83
|
+
const typedEl = suffix ? byId.get(`Extension.extension:${sliceName}.value[x]:value${suffix}`) : undefined;
|
|
84
|
+
if (!typedEl)
|
|
85
|
+
continue;
|
|
86
|
+
binding = binding ?? readBinding(typedEl);
|
|
87
|
+
if (valueTypes.length === 0)
|
|
88
|
+
valueTypes = typeCodes(typedEl);
|
|
89
|
+
}
|
|
90
|
+
out.push({
|
|
91
|
+
sliceName,
|
|
92
|
+
url,
|
|
93
|
+
min: typeof el.min === 'number' ? el.min : 0,
|
|
94
|
+
valueTypes,
|
|
95
|
+
binding,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
@@ -181,6 +181,48 @@ export function alignFields(newFields, oldFields) {
|
|
|
181
181
|
// Return the original oldFields and the aligned newFields
|
|
182
182
|
return { alignedNewFields, oldFields };
|
|
183
183
|
}
|
|
184
|
+
/** A type the parser could not pin down from the element itself. */
|
|
185
|
+
function isUnstatedType(type) {
|
|
186
|
+
return !type || type === 'any';
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Carry forward what a differential element left unstated.
|
|
190
|
+
*
|
|
191
|
+
* A differential says only what it changes, so an element that constrains
|
|
192
|
+
* cardinality alone carries no `type` and an element that constrains `min` alone
|
|
193
|
+
* carries no `max`. Both are inherited rather than defaulted away.
|
|
194
|
+
*/
|
|
195
|
+
function inheritUnstatedFromBase(field, base) {
|
|
196
|
+
if (!base)
|
|
197
|
+
return field;
|
|
198
|
+
const merged = { ...field };
|
|
199
|
+
if (isUnstatedType(merged.type) && !isUnstatedType(base.type)) {
|
|
200
|
+
merged.type = base.type;
|
|
201
|
+
}
|
|
202
|
+
if (!merged.baseTypeCode && base.baseTypeCode) {
|
|
203
|
+
merged.baseTypeCode = base.baseTypeCode;
|
|
204
|
+
}
|
|
205
|
+
// `max` absent means the differential did not narrow the upper bound, so the
|
|
206
|
+
// base's bound — and with it whether the element repeats — still holds.
|
|
207
|
+
if (merged.max === undefined && base.max !== undefined) {
|
|
208
|
+
merged.max = base.max;
|
|
209
|
+
merged.isArray = base.isArray;
|
|
210
|
+
}
|
|
211
|
+
if (!merged.typeOptions?.length && base.typeOptions?.length) {
|
|
212
|
+
merged.typeOptions = base.typeOptions;
|
|
213
|
+
}
|
|
214
|
+
// An extension slice is recognised at runtime by the canonical its profile
|
|
215
|
+
// names. A derived profile that only tightens cardinality does not restate it —
|
|
216
|
+
// KBV_PR_ERP_Medication_PZN redeclares extension:Kategorie and extension:Normgroesse
|
|
217
|
+
// with no type.profile, while KBV_PR_Base_Medication gives them
|
|
218
|
+
// KBV_EX_Base_Medication_Type and .../normgroesse. Dropping the base's left two
|
|
219
|
+
// of four slices unidentifiable, so the closed-slicing check on
|
|
220
|
+
// Medication.extension could not build its allowed set and stayed silent.
|
|
221
|
+
if (!merged.profileUrls?.length && base.profileUrls?.length) {
|
|
222
|
+
merged.profileUrls = base.profileUrls;
|
|
223
|
+
}
|
|
224
|
+
return merged;
|
|
225
|
+
}
|
|
184
226
|
export function mergeFields(newFields, oldFields) {
|
|
185
227
|
const fieldMap = new Map();
|
|
186
228
|
// Create unique key for field - use elementId if available (includes slice names for child elements)
|
|
@@ -208,9 +250,22 @@ export function mergeFields(newFields, oldFields) {
|
|
|
208
250
|
}
|
|
209
251
|
fieldMap.set(key, field);
|
|
210
252
|
}
|
|
211
|
-
// Overwrite or add newFields to the map
|
|
253
|
+
// Overwrite or add newFields to the map.
|
|
254
|
+
//
|
|
255
|
+
// A differential element states only what it *changes*. Replacing the base
|
|
256
|
+
// field wholesale therefore threw away everything it stayed silent about:
|
|
257
|
+
// KBV constrains Medication.amount.numerator.value to max=0 and says nothing
|
|
258
|
+
// about its type, so the merged field read `any` and every type-aware check
|
|
259
|
+
// skipped it — HL7 reported "The value 'Example value' is not a valid decimal"
|
|
260
|
+
// where we said nothing. The same silence on `max` left Medication.ingredient
|
|
261
|
+
// with isArray=false despite being 0..* in the base, so paths beneath it were
|
|
262
|
+
// never walked.
|
|
263
|
+
//
|
|
264
|
+
// Only genuinely absent properties are filled in; anything the differential
|
|
265
|
+
// states still wins.
|
|
212
266
|
for (const field of newFields) {
|
|
213
|
-
|
|
267
|
+
const key = getFieldKey(field);
|
|
268
|
+
fieldMap.set(key, inheritUnstatedFromBase(field, fieldMap.get(key)));
|
|
214
269
|
}
|
|
215
270
|
// Return the merged fields as an array
|
|
216
271
|
return Array.from(fieldMap.values());
|
|
@@ -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. */
|
|
@@ -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
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed slicing enforcement.
|
|
3
|
+
*
|
|
4
|
+
* `slicing.rules = 'closed'` means an instance element matching none of the declared
|
|
5
|
+
* slices is an error. Nothing enforced it: `slicingRules` was read in the interface
|
|
6
|
+
* emitter through an `as unknown as` cast and never written by any parser, so every
|
|
7
|
+
* slicing looked open. HL7 reports these as
|
|
8
|
+
* "This element does not match any known slice defined in the profile <url>
|
|
9
|
+
* and slicing is CLOSED: <path>"
|
|
10
|
+
* and they were the whole `entry` category of the remaining random-parity failures
|
|
11
|
+
* (davinci-dtr, kbv-erp — issue #138).
|
|
12
|
+
*
|
|
13
|
+
* Two discriminator shapes are enforced:
|
|
14
|
+
* - `{value, url}` on an extension slot — the member's url must be declared
|
|
15
|
+
* - `{type, $this}` on a choice element — the chosen type must be declared
|
|
16
|
+
*
|
|
17
|
+
* Deliberately limited to slicings introduced at an unsliced path. A slicing declared
|
|
18
|
+
* *inside* another slice (`Medication.extension:Kategorie.value[x]`) shares its
|
|
19
|
+
* unsliced name with its siblings, so the field model cannot tell the sets apart:
|
|
20
|
+
* merging them applies one slice's allowed types to every member and reports a
|
|
21
|
+
* conformant resource. Enforcing those needs slice-scoped traversal and is left off
|
|
22
|
+
* rather than approximated.
|
|
23
|
+
*/
|
|
24
|
+
import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
25
|
+
const CHOICE_SUFFIX = '[x]';
|
|
26
|
+
/** The url an instance of this extension slice carries (pinned `url` child, else profile). */
|
|
27
|
+
function instanceUrl(slice, fields) {
|
|
28
|
+
const sliceElemId = (slice.elementId || slice.name).replace(/^[^.]+\./, '');
|
|
29
|
+
const sliceFullId = slice.sliceName && !sliceElemId.includes(':')
|
|
30
|
+
? `${sliceElemId}:${slice.sliceName}`
|
|
31
|
+
: sliceElemId;
|
|
32
|
+
for (const f of fields) {
|
|
33
|
+
const childId = (f.elementId || f.name).replace(/^[^.]+\./, '');
|
|
34
|
+
if (childId === sliceFullId + '.url' && typeof f.fixedValue === 'string')
|
|
35
|
+
return f.fixedValue;
|
|
36
|
+
}
|
|
37
|
+
return slice.profileUrls?.length ? stripVersionFromCanonicalUrl(slice.profileUrls[0]) : undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The property name a choice slice occupies.
|
|
41
|
+
*
|
|
42
|
+
* A choice slice is conventionally named after the property (`valueCoding`), so the
|
|
43
|
+
* slice name is used when it already starts with the base property; the declared
|
|
44
|
+
* type is the fallback (`value[x]` + type `Coding` → `valueCoding`).
|
|
45
|
+
*/
|
|
46
|
+
function choiceProperty(baseProp, slice) {
|
|
47
|
+
if (slice.sliceName?.startsWith(baseProp))
|
|
48
|
+
return slice.sliceName;
|
|
49
|
+
const code = slice.typeOptions?.[0]?.code ?? slice.baseTypeCode ?? slice.type;
|
|
50
|
+
if (!code)
|
|
51
|
+
return undefined;
|
|
52
|
+
return `${baseProp}${code.charAt(0).toUpperCase()}${code.slice(1)}`;
|
|
53
|
+
}
|
|
54
|
+
/** Classify a closed slicing, or undefined when it is not one this module enforces. */
|
|
55
|
+
function classify(leaf) {
|
|
56
|
+
if (leaf === 'extension' || leaf === 'modifierExtension')
|
|
57
|
+
return 'extension-url';
|
|
58
|
+
if (leaf.endsWith(CHOICE_SUFFIX))
|
|
59
|
+
return 'choice-type';
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Bind `_csElem` to every element at `segments`, flattening array hops.
|
|
64
|
+
*
|
|
65
|
+
* Always the flattening walk, never a plain `at()`: the field model's array flags do
|
|
66
|
+
* not always agree with the instance (Medication.ingredient is 0..* yet was read as a
|
|
67
|
+
* single element), and `elementsAt` is correct for both shapes.
|
|
68
|
+
*/
|
|
69
|
+
function walk(segments, body) {
|
|
70
|
+
const pathArgs = segments.map(s => `'${s}'`).join(', ');
|
|
71
|
+
return `\n for (const _csElem of elementsAt(resource, [${pathArgs}])) {${body}\n }`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Emit closed-slicing checks for every element that introduces a closed slicing.
|
|
75
|
+
*/
|
|
76
|
+
export function generateClosedSlicingValidations(fields, profileUrl, baseResourceType) {
|
|
77
|
+
const out = [];
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
for (const field of fields) {
|
|
80
|
+
if (field.slicingRules !== 'closed' || field.sliceName)
|
|
81
|
+
continue;
|
|
82
|
+
// Address the slicing by its element id so slice qualifiers stay visible.
|
|
83
|
+
const elemId = (field.elementId || field.name).replace(/^[^.]+\./, '');
|
|
84
|
+
if (!elemId || seen.has(elemId))
|
|
85
|
+
continue;
|
|
86
|
+
// A slicing nested inside another slice cannot be addressed from the root.
|
|
87
|
+
if (elemId.includes(':'))
|
|
88
|
+
continue;
|
|
89
|
+
const segments = elemId.split('.');
|
|
90
|
+
const leaf = segments[segments.length - 1];
|
|
91
|
+
const kind = classify(leaf);
|
|
92
|
+
if (!kind)
|
|
93
|
+
continue;
|
|
94
|
+
// A choice segment above the leaf is not a JSON property name, so the walk
|
|
95
|
+
// cannot reach past it.
|
|
96
|
+
if (segments.slice(0, -1).some(s => s.endsWith(CHOICE_SUFFIX)))
|
|
97
|
+
continue;
|
|
98
|
+
const slices = fields.filter(f => f.sliceName && f.name === field.name);
|
|
99
|
+
if (slices.length === 0)
|
|
100
|
+
continue;
|
|
101
|
+
const fhirPath = `${baseResourceType ?? 'Resource'}.${elemId}`;
|
|
102
|
+
const profileRef = profileUrl ?? (baseResourceType ?? '');
|
|
103
|
+
const message = `This element does not match any known slice defined in the profile ${profileRef} and slicing is CLOSED: ${fhirPath}`;
|
|
104
|
+
if (kind === 'extension-url') {
|
|
105
|
+
const urls = slices.map(s => instanceUrl(s, fields)).filter((u) => !!u);
|
|
106
|
+
// Every slice must be identifiable, or a conformant member gets reported.
|
|
107
|
+
if (urls.length !== slices.length)
|
|
108
|
+
continue;
|
|
109
|
+
const allowed = `[${[...new Set(urls)].map(u => `"${u}"`).join(', ')}]`;
|
|
110
|
+
// A member with no url matches no slice either, and HL7 reports it: every
|
|
111
|
+
// discriminator here is `<canonical> in url`, so an absent url satisfies none
|
|
112
|
+
// of them. Firing only on a *present* url meant the member KBV's random
|
|
113
|
+
// Medication carries — `{extension:[…]}` with no url at all — went unreported
|
|
114
|
+
// while HL7 named all four slices it failed to match. The missing url itself
|
|
115
|
+
// is a separate finding (Extension.url: minimum required = 1) that both
|
|
116
|
+
// validators already agree on; this is the slicing violation on top of it.
|
|
117
|
+
out.push(`
|
|
118
|
+
// Closed slicing on ${fhirPath}: only the declared extension urls are allowed${walk(segments, `
|
|
119
|
+
const _csUrl = at(_csElem, 'url');
|
|
120
|
+
if (typeof _csUrl !== 'string' || !${allowed}.includes(_csUrl)) {
|
|
121
|
+
errors.push("${message}" + (typeof _csUrl === 'string' ? " (url = " + _csUrl + ")" : " (no url)"));
|
|
122
|
+
}`)}`);
|
|
123
|
+
seen.add(elemId);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// choice-type: the slot is `foo[x]`, so an instance writes `fooCoding` etc.
|
|
127
|
+
const baseProp = leaf.slice(0, -CHOICE_SUFFIX.length);
|
|
128
|
+
const props = slices.map(s => choiceProperty(baseProp, s)).filter((p) => !!p);
|
|
129
|
+
if (props.length !== slices.length)
|
|
130
|
+
continue;
|
|
131
|
+
const allowedProps = `[${[...new Set(props)].map(p => `"${p}"`).join(', ')}]`;
|
|
132
|
+
// The choice lives on the parent element — `foo[x]` is not a property name.
|
|
133
|
+
const body = `
|
|
134
|
+
const _csKey = Object.keys(node(_csElem)).find((k) => k.startsWith("${baseProp}") && k !== "${baseProp}");
|
|
135
|
+
if (_csKey !== undefined && !${allowedProps}.includes(_csKey)) {
|
|
136
|
+
errors.push("${message}.ofType(" + _csKey.slice(${baseProp.length}).toLowerCase() + ")");
|
|
137
|
+
}`;
|
|
138
|
+
const parentSegments = segments.slice(0, -1);
|
|
139
|
+
out.push(`
|
|
140
|
+
// Closed slicing on ${fhirPath}: only the declared types are allowed${parentSegments.length === 0
|
|
141
|
+
? `\n {\n const _csElem = resource;${body}\n }`
|
|
142
|
+
: walk(parentSegments, body)}`);
|
|
143
|
+
seen.add(elemId);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|