babelfhir-ts 1.5.19 → 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/constants.js +19 -0
- package/out/src/generator/core/extensionSdReader.js +99 -0
- package/out/src/generator/core/utils.js +97 -3
- package/out/src/generator/emitters/class/classTemplate.js +8 -2
- package/out/src/generator/emitters/class/sliceElementDefaults.js +5 -3
- 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 +15 -4
- package/out/src/generator/emitters/interface/importManager.js +28 -2
- package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +21 -16
- package/out/src/generator/emitters/interface/interfaceFieldUtils.js +16 -0
- 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/sliceDelegation.js +450 -0
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +130 -299
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +89 -16
- package/out/src/generator/emitters/validator/subExtensionScope.js +100 -0
- package/out/src/generator/emitters/validator/validatorBindingBuilder.js +113 -237
- package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +13 -4
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +104 -63
- package/out/src/generator/emitters/validator/validatorExpressions.js +54 -0
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +492 -46
- package/out/src/generator/emitters/validator/validatorGenerator.js +170 -169
- package/out/src/generator/emitters/validator/validatorRuntime.js +279 -0
- package/out/src/generator/emitters/validator/validatorTemplates.js +186 -54
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +29 -19
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +16 -1
- package/out/src/generator/index.js +21 -11
- package/out/src/generator/parser/packageParser.js +1 -8
- package/out/src/generator/parser/sdParser.js +1 -0
- package/out/src/generator/sdProcessor.js +303 -30
- package/out/src/generator/sdProcessorHelpers.js +1 -1
- package/out/src/generator/sdProcessorRequiredFields.js +120 -0
- package/out/src/main.js +37 -11
- package/package.json +3 -1
- package/parity-matrix.json +18 -18
|
@@ -1,7 +1,186 @@
|
|
|
1
|
+
import { choicePropertyName, narrowedChoiceType } from '../../core/utils.js';
|
|
2
|
+
/**
|
|
3
|
+
* The element slots FHIR types as `Resource`, i.e. the ones that hold an inline
|
|
4
|
+
* resource rather than a reference to one: DomainResource.contained,
|
|
5
|
+
* Bundle.entry.resource and Parameters.parameter.resource. A profile narrows them
|
|
6
|
+
* by naming concrete resource types — QuestionnaireResponse.contained restricted
|
|
7
|
+
* to Questionnaire, for instance.
|
|
8
|
+
*/
|
|
9
|
+
export const INLINE_RESOURCE_SLOTS = new Set(['contained', 'resource']);
|
|
10
|
+
/** Type codes that place no restriction on which resource may appear. */
|
|
11
|
+
const ABSTRACT_RESOURCE_TYPES = new Set(['Resource', 'DomainResource']);
|
|
12
|
+
/**
|
|
13
|
+
* A complex-typed element below the root must still be a JSON object.
|
|
14
|
+
*
|
|
15
|
+
* The depth-1 pass covers only top-level fields, and it also skips container types,
|
|
16
|
+
* so Bundle.entry.resource was never checked at any depth — HL7 reports
|
|
17
|
+
* "The property resource must be an Object, not a Primitive property
|
|
18
|
+
* (at Bundle.entry[0].resource)" and we reported nothing. Containers are included
|
|
19
|
+
* here: a Resource-valued element holding a primitive is malformed regardless.
|
|
20
|
+
*
|
|
21
|
+
* Only fires on a value that is present and primitive, which is the same condition
|
|
22
|
+
* HL7 uses, so this cannot flag a conformant resource.
|
|
23
|
+
*/
|
|
24
|
+
function buildNestedObjectTypeValidations(fields, arrayFieldPaths, result) {
|
|
25
|
+
const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const field of fields) {
|
|
28
|
+
if (field.sliceName || field.isArray)
|
|
29
|
+
continue;
|
|
30
|
+
const fullPath = field.name || '';
|
|
31
|
+
if (fullPath.includes('[x]'))
|
|
32
|
+
continue;
|
|
33
|
+
const rel = fullPath.replace(/^[^.]+\./, '');
|
|
34
|
+
if (!rel.includes('.') || seen.has(rel))
|
|
35
|
+
continue;
|
|
36
|
+
const fieldType = field.type || field.baseTypeCode;
|
|
37
|
+
if (!fieldType || isFhirPrimitive(fieldType))
|
|
38
|
+
continue;
|
|
39
|
+
// A primitive-valued element is checked by the format validations instead.
|
|
40
|
+
if (fieldType.startsWith('http://hl7.org/fhirpath/'))
|
|
41
|
+
continue;
|
|
42
|
+
const segments = rel.split('.');
|
|
43
|
+
const parents = segments.slice(0, -1);
|
|
44
|
+
const leaf = segments[segments.length - 1];
|
|
45
|
+
// Walk the parents, iterating any array level so each member is checked.
|
|
46
|
+
let expr = 'resource';
|
|
47
|
+
const open = [];
|
|
48
|
+
let indent = ' ';
|
|
49
|
+
parents.forEach((segment, i) => {
|
|
50
|
+
const pathToHere = parents.slice(0, i + 1).join('.');
|
|
51
|
+
if (isArrayPath(pathToHere)) {
|
|
52
|
+
const loopVar = `_soEl${i}`;
|
|
53
|
+
open.push(`${indent}for (const ${loopVar} of arr(at(${expr}, '${segment}'))) {`);
|
|
54
|
+
indent += ' ';
|
|
55
|
+
expr = loopVar;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
expr = `at(${expr}, '${segment}')`;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
seen.add(rel);
|
|
62
|
+
const closes = open.map((_l, i) => ' '.repeat(open.length - i + 1) + '}').join('\n');
|
|
63
|
+
const body = `${indent}const _soValue = at(${expr}, '${leaf}');
|
|
64
|
+
${indent}if (_soValue !== undefined && _soValue !== null && typeof _soValue !== 'object') {
|
|
65
|
+
${indent} errors.push("The property ${leaf} must be an Object, not a Primitive property (at ${rel})");
|
|
66
|
+
${indent}}`;
|
|
67
|
+
result.push(`
|
|
68
|
+
// Structural type check: ${rel} must be an Object
|
|
69
|
+
{
|
|
70
|
+
${[...open, body, closes].filter(Boolean).join('\n')}
|
|
71
|
+
}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build inline-resource type validations.
|
|
76
|
+
*
|
|
77
|
+
* An inline resource must carry a `resourceType`, and where the profile narrows
|
|
78
|
+
* the slot that type must be one it allows. We checked neither, so a resource
|
|
79
|
+
* generated without a resourceType — or of a type the profile forbids — passed
|
|
80
|
+
* while HL7 reported "Unable to find resourceType property" and "The type 'X' is
|
|
81
|
+
* not valid - no resources allowed here".
|
|
82
|
+
*
|
|
83
|
+
* Slots left as Resource/DomainResource are unrestricted and only get the
|
|
84
|
+
* resourceType presence check, which is what HL7 does too.
|
|
85
|
+
*/
|
|
86
|
+
export function buildInlineResourceTypeValidations(fields, profileUrl) {
|
|
87
|
+
const result = [];
|
|
88
|
+
// A slot can appear twice once base and profile fields are merged; emitting both
|
|
89
|
+
// would report the same violation twice.
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const field of fields) {
|
|
92
|
+
if (field.sliceName)
|
|
93
|
+
continue;
|
|
94
|
+
const rel = (field.name || '').replace(/^[^.]+\./, '');
|
|
95
|
+
if (seen.has(rel))
|
|
96
|
+
continue;
|
|
97
|
+
// Only the top-level slot for now; Bundle.entry.resource sits under an array
|
|
98
|
+
// and is covered by the Bundle-specific entry validation.
|
|
99
|
+
if (!rel || rel.includes('.') || !INLINE_RESOURCE_SLOTS.has(rel))
|
|
100
|
+
continue;
|
|
101
|
+
const declaredCodes = (field.typeOptions?.length
|
|
102
|
+
? field.typeOptions.map(option => option.code)
|
|
103
|
+
: [field.type || field.baseTypeCode || '']).filter(code => /^[A-Z][A-Za-z]+$/.test(code));
|
|
104
|
+
if (declaredCodes.length === 0)
|
|
105
|
+
continue;
|
|
106
|
+
seen.add(rel);
|
|
107
|
+
const allowed = [...new Set(declaredCodes)].filter(code => !ABSTRACT_RESOURCE_TYPES.has(code));
|
|
108
|
+
const member = field.isArray ? '_irItem' : `at(resource, '${rel}')`;
|
|
109
|
+
const typeCheck = allowed.length > 0
|
|
110
|
+
? `
|
|
111
|
+
} else if (typeof _irType === 'string' && !${JSON.stringify(allowed)}.includes(_irType)) {
|
|
112
|
+
errors.push("The type '" + _irType + "' is not valid - no resources allowed here (allowed = ${allowed.join(', ')})");`
|
|
113
|
+
: '';
|
|
114
|
+
// A contained resource is only reachable through a '#id' reference, so FHIR
|
|
115
|
+
// requires the id — HL7 reports it right alongside the missing resourceType.
|
|
116
|
+
const idCheck = rel === 'contained'
|
|
117
|
+
? `
|
|
118
|
+
if (at(${member}, 'id') === undefined) {
|
|
119
|
+
errors.push("Resource requires an id, but none is present");
|
|
120
|
+
}`
|
|
121
|
+
: '';
|
|
122
|
+
// An untyped inline resource reads as a bare `Resource`, so a narrowed slot is
|
|
123
|
+
// also violated on type. HL7 reports all three together — the missing property,
|
|
124
|
+
// the profile's declared type, and the slot's allowed set — and reporting only
|
|
125
|
+
// the first left the other two attributed to HL7 alone.
|
|
126
|
+
const untypedIsWrongType = allowed.length > 0
|
|
127
|
+
? `
|
|
128
|
+
errors.push("The Profile '${(profileUrl || '').replace(/'/g, "\\'")}' definition allows for the type ${allowed.join(', ')} but found type Resource");
|
|
129
|
+
errors.push("The type 'Resource' is not valid - no resources allowed here (allowed = ${allowed.join(', ')})");`
|
|
130
|
+
: '';
|
|
131
|
+
const body = `
|
|
132
|
+
const _irType = at(${member}, 'resourceType');
|
|
133
|
+
if (_irType === undefined) {
|
|
134
|
+
errors.push("Unable to find resourceType property");${untypedIsWrongType}${typeCheck}
|
|
135
|
+
}${idCheck}`;
|
|
136
|
+
result.push(field.isArray
|
|
137
|
+
? `
|
|
138
|
+
// Inline resource check for ${rel}${allowed.length > 0 ? ` (restricted to ${allowed.join(', ')})` : ''}
|
|
139
|
+
for (const _irItem of arr(at(resource, '${rel}'))) {${body}
|
|
140
|
+
}`
|
|
141
|
+
: `
|
|
142
|
+
// Inline resource check for ${rel}${allowed.length > 0 ? ` (restricted to ${allowed.join(', ')})` : ''}
|
|
143
|
+
if (at(resource, '${rel}') !== undefined) {${body}
|
|
144
|
+
}`);
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
1
148
|
/**
|
|
2
149
|
* Build deeply nested required field validations.
|
|
3
150
|
* For fields like component.value[x].data (min=1) where the parent is an array.
|
|
4
151
|
*/
|
|
152
|
+
/**
|
|
153
|
+
* Rewrite an element id whose only slice qualifiers name choice-type variants into
|
|
154
|
+
* the concrete JSON path, or undefined when any qualifier discriminates a subset.
|
|
155
|
+
*
|
|
156
|
+
* `ingredient.item[x]:itemCodeableConcept.text` → `ingredient.itemCodeableConcept.text`.
|
|
157
|
+
* A qualifier is only accepted when its base ends in `[x]` and the slice name starts
|
|
158
|
+
* with the base property, which is the convention that makes the slice name the
|
|
159
|
+
* property name. `identifier:NPI.value` fails both tests and is still skipped: NPI
|
|
160
|
+
* is one identifier among several, so requiring its children of every identifier
|
|
161
|
+
* would report a conformant resource.
|
|
162
|
+
*/
|
|
163
|
+
function leafFieldName(parts) {
|
|
164
|
+
return parts[parts.length - 1];
|
|
165
|
+
}
|
|
166
|
+
function resolveChoiceSliceSegments(elementIdRel) {
|
|
167
|
+
const segments = elementIdRel.split('.');
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const segment of segments) {
|
|
170
|
+
if (!segment.includes(':')) {
|
|
171
|
+
out.push(segment.replace(/\[x\]$/, ''));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const [base, sliceName] = segment.split(':');
|
|
175
|
+
if (!base.endsWith('[x]') || !sliceName)
|
|
176
|
+
return undefined;
|
|
177
|
+
const baseProp = base.slice(0, -'[x]'.length);
|
|
178
|
+
if (!sliceName.startsWith(baseProp) || sliceName === baseProp)
|
|
179
|
+
return undefined;
|
|
180
|
+
out.push(sliceName);
|
|
181
|
+
}
|
|
182
|
+
return out.join('.');
|
|
183
|
+
}
|
|
5
184
|
export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
|
|
6
185
|
const result = [];
|
|
7
186
|
fields
|
|
@@ -15,44 +194,87 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
|
|
|
15
194
|
let rel = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/g, '');
|
|
16
195
|
if (!rel)
|
|
17
196
|
return;
|
|
18
|
-
// Skip slice-specific fields
|
|
197
|
+
// Skip slice-specific fields: a slice's requirements bind only its own
|
|
198
|
+
// members, and this builder emits an unconditional check.
|
|
199
|
+
//
|
|
200
|
+
// One exception, and it is not really a subset. A choice-type slice names a
|
|
201
|
+
// JSON property rather than discriminating within one: FHIR writes
|
|
202
|
+
// `item[x]:itemCodeableConcept` for the CodeableConcept variant of `item[x]`,
|
|
203
|
+
// and an instance carrying that variant writes `itemCodeableConcept`. So the
|
|
204
|
+
// slice *is* the property, and its required children apply whenever the
|
|
205
|
+
// property is present — which is what HL7 reports as
|
|
206
|
+
// "Medication.ingredient.item[x]:itemCodeableConcept.text: minimum required = 1"
|
|
207
|
+
// where we said nothing, because `field.name` flattens `item[x]` to `item`
|
|
208
|
+
// and loses which variant was meant.
|
|
19
209
|
const elementId = field.elementId || relFull;
|
|
20
|
-
|
|
21
|
-
|
|
210
|
+
let choiceSliceResolved = false;
|
|
211
|
+
if (elementId.includes(':')) {
|
|
212
|
+
const resolved = resolveChoiceSliceSegments(elementId.replace(/^[^.]+\./, ''));
|
|
213
|
+
if (!resolved)
|
|
214
|
+
return;
|
|
215
|
+
rel = resolved;
|
|
216
|
+
choiceSliceResolved = true;
|
|
217
|
+
}
|
|
22
218
|
const pathParts = rel.split('.');
|
|
23
219
|
if (pathParts.length < 2)
|
|
24
220
|
return;
|
|
25
|
-
// Resolve choice type in path
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
221
|
+
// Resolve choice type in path. Skipped when the element id already named the
|
|
222
|
+
// variant: this block reads the *leaf's* own type, which for a nested leaf
|
|
223
|
+
// (…item[x]:itemCodeableConcept.text) is the text's type, not the choice's.
|
|
224
|
+
if (relFull.includes('[x]') && !choiceSliceResolved) {
|
|
225
|
+
// The JSON property name comes from the element's *own* type, not its parent's.
|
|
226
|
+
// Reading the parent discarded the check whenever that parent was a container:
|
|
227
|
+
// Claim.item.location[x] is typed CodeableConcept, so the property is
|
|
228
|
+
// locationCodeableConcept, but item is a BackboneElement and the whole check
|
|
229
|
+
// was dropped — HL7 reported "Claim.item.location[x]: minimum required = 1"
|
|
230
|
+
// alone.
|
|
231
|
+
const ownTypes = (field.typeOptions?.length
|
|
232
|
+
? field.typeOptions.map(option => option.code)
|
|
233
|
+
: [field.type || field.baseTypeCode || '']).filter(code => /^[A-Za-z][A-Za-z0-9]*$/.test(code)
|
|
234
|
+
&& code !== 'BackboneElement' && code !== 'Element' && code !== 'any');
|
|
235
|
+
const uniqueTypes = [...new Set(ownTypes)];
|
|
236
|
+
// Only an unambiguous choice yields one property name. A multi-typed choice
|
|
237
|
+
// may legitimately carry any variant, and picking one would flag a conformant
|
|
238
|
+
// resource — which is what the previous depth guard was protecting against.
|
|
239
|
+
if (uniqueTypes.length !== 1)
|
|
39
240
|
return;
|
|
40
|
-
|
|
241
|
+
const choiceSuffix = uniqueTypes[0].charAt(0).toUpperCase() + uniqueTypes[0].slice(1);
|
|
242
|
+
rel = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/, choiceSuffix);
|
|
41
243
|
}
|
|
42
244
|
const resolvedParts = rel.split('.');
|
|
43
|
-
|
|
44
|
-
// of array parents where the substitution may not apply to all instances
|
|
45
|
-
if (relFull.includes('[x]') && resolvedParts.length < 3)
|
|
46
|
-
return;
|
|
47
|
-
const arrayParent = resolvedParts[0];
|
|
48
|
-
if (!arrayFieldPaths.has(arrayParent) && !arrayFieldPaths.has(arrayParent.split(':')[0]))
|
|
49
|
-
return;
|
|
245
|
+
const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
|
|
50
246
|
// Check which intermediate parts are arrays
|
|
51
247
|
const intermediateParts = resolvedParts.slice(1, -1);
|
|
52
|
-
const hasIntermediateArray = intermediateParts.some((_part, idx) =>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
248
|
+
const hasIntermediateArray = intermediateParts.some((_part, idx) => isArrayPath(resolvedParts.slice(0, idx + 2).join('.')));
|
|
249
|
+
const arrayParent = resolvedParts[0];
|
|
250
|
+
const topIsArray = isArrayPath(arrayParent);
|
|
251
|
+
// An array is needed somewhere above the leaf, but it need not be the first
|
|
252
|
+
// segment: the flat branches below index straight into resource.<arrayParent>,
|
|
253
|
+
// whereas the nested-loop branch walks any shape. Requiring the *first*
|
|
254
|
+
// segment to be an array meant a path whose only array sits deeper
|
|
255
|
+
// (Observation.code.coding.code — code is 0..1, coding is the array) bailed
|
|
256
|
+
// out and emitted nothing, leaving HL7 to report the missing element alone.
|
|
257
|
+
if (!topIsArray && !hasIntermediateArray) {
|
|
258
|
+
// A choice-slice path is emitted even when no level is a known array.
|
|
259
|
+
// `Medication.ingredient` is 0..* in the base but the KBV differential
|
|
260
|
+
// constrains only its `min`, so the merged field carries no max and
|
|
261
|
+
// `isArray` reads false — the check bailed and HL7 reported
|
|
262
|
+
// "…itemCodeableConcept.text: minimum required = 1" alone. `elementsAt`
|
|
263
|
+
// flattens an array level and passes a single object through unchanged,
|
|
264
|
+
// so it is right either way and does not depend on that flag.
|
|
265
|
+
if (!choiceSliceResolved)
|
|
266
|
+
return;
|
|
267
|
+
const walk = resolvedParts.slice(0, -1).map(p => `'${p}'`).join(', ');
|
|
268
|
+
result.push(`
|
|
269
|
+
// Nested required field: ${rel} (min=1)
|
|
270
|
+
for (const _nrEl of elementsAt(resource, [${walk}])) {
|
|
271
|
+
const _nrLeaf = at(_nrEl, '${leafFieldName(resolvedParts)}');
|
|
272
|
+
if (_nrLeaf === undefined || _nrLeaf === null) {
|
|
273
|
+
errors.push("Missing required member: '${leafFieldName(resolvedParts)}'");
|
|
274
|
+
}
|
|
275
|
+
}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
56
278
|
const leafField = resolvedParts[resolvedParts.length - 1];
|
|
57
279
|
// Check if the leaf field itself is an array — if so, don't flag Array values as absent
|
|
58
280
|
const fullLeafPath = resolvedParts.join('.');
|
|
@@ -148,17 +370,88 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
|
|
|
148
370
|
/**
|
|
149
371
|
* Build fixed value validations (fixedValue on fields).
|
|
150
372
|
*/
|
|
151
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Emit a fixed-value check for an element below the resource root.
|
|
375
|
+
*
|
|
376
|
+
* Uses HL7's wording, whose `#Type.path` fragment is what attributes the finding
|
|
377
|
+
* to the pinned leaf: a bare message would land on `constraint` instead of
|
|
378
|
+
* `reference`. Array parents are walked so each member is checked.
|
|
379
|
+
*/
|
|
380
|
+
function emitNestedFixedValue(result, rel, field, arrayFieldPaths, profileUrl, baseResourceType) {
|
|
381
|
+
const fixedValue = field.fixedValue;
|
|
382
|
+
if (typeof fixedValue !== 'string' && typeof fixedValue !== 'number' && typeof fixedValue !== 'boolean')
|
|
383
|
+
return;
|
|
384
|
+
// A pin declared on a slice applies only to that slice's members. field.name
|
|
385
|
+
// carries no slice identity, so emitting it against the base element applied one
|
|
386
|
+
// slice's pin to every member: AuditEvent.agent:user.requestor is fixed to true,
|
|
387
|
+
// and checking it on all agents failed agent:client and agent:server, which
|
|
388
|
+
// legitimately carry false. Same rule the forbidden-field builder follows.
|
|
389
|
+
// Slice qualifiers appear in elementId for most fields and in name for some, so
|
|
390
|
+
// both are checked — relying on elementId alone let a Parameters part slice
|
|
391
|
+
// through.
|
|
392
|
+
const hasSliceQualifier = [field.elementId || '', field.name || '', rel]
|
|
393
|
+
.some(candidate => candidate.split('.').some(segment => segment.includes(':')));
|
|
394
|
+
if (hasSliceQualifier)
|
|
395
|
+
return;
|
|
396
|
+
const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
|
|
397
|
+
const segments = rel.split('.');
|
|
398
|
+
const parents = segments.slice(0, -1);
|
|
399
|
+
const leaf = segments[segments.length - 1];
|
|
400
|
+
let expr = 'resource';
|
|
401
|
+
const open = [];
|
|
402
|
+
let indent = ' ';
|
|
403
|
+
parents.forEach((segment, i) => {
|
|
404
|
+
const pathToHere = parents.slice(0, i + 1).join('.');
|
|
405
|
+
if (isArrayPath(pathToHere)) {
|
|
406
|
+
const loopVar = `_fvParent${i}`;
|
|
407
|
+
open.push(`${indent}for (const ${loopVar} of arr(at(${expr}, '${segment}'))) {`);
|
|
408
|
+
indent += ' ';
|
|
409
|
+
expr = loopVar;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
expr = `at(${expr}, '${segment}')`;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
const members = field.isArray ? `arr(at(${expr}, '${leaf}'))` : `[at(${expr}, '${leaf}')]`;
|
|
416
|
+
const literal = JSON.stringify(fixedValue);
|
|
417
|
+
const anchor = `${(profileUrl || '').replace(/'/g, "\\'")}#${baseResourceType || 'Resource'}.${rel}`;
|
|
418
|
+
const body = `${indent}for (const _fvValue of ${members}) {
|
|
419
|
+
${indent} if (_fvValue !== undefined && _fvValue !== null && _fvValue !== ${literal}) {
|
|
420
|
+
${indent} errors.push("Value is '" + _fvValue + "' but is fixed to '${String(fixedValue).replace(/"/g, '\\"')}' in the profile ${anchor}");
|
|
421
|
+
${indent} }
|
|
422
|
+
${indent}}`;
|
|
423
|
+
const closes = open.map((_l, i) => ' '.repeat(open.length - i + 1) + '}').join('\n');
|
|
424
|
+
result.push(`
|
|
425
|
+
// Fixed value constraint for ${rel}
|
|
426
|
+
{
|
|
427
|
+
${[...open, body, closes].filter(Boolean).join('\n')}
|
|
428
|
+
}`);
|
|
429
|
+
}
|
|
430
|
+
export function buildFixedValueValidations(fields, arrayFieldPaths, profileUrl, baseResourceType) {
|
|
152
431
|
const result = [];
|
|
153
432
|
fields
|
|
154
433
|
.filter((field) => field.fixedValue !== undefined)
|
|
155
434
|
.forEach((field) => {
|
|
156
435
|
const relFull = field.name || '';
|
|
157
|
-
|
|
436
|
+
let rel = relFull.replace(/^[^.]+\./, '');
|
|
158
437
|
if (!rel)
|
|
159
438
|
return;
|
|
439
|
+
// A choice element is read through the property named for its type. Stripping
|
|
440
|
+
// `[x]` produced `resource.value`, which no resource carries and which the
|
|
441
|
+
// emitted interface — now naming the concrete property — rejects outright.
|
|
442
|
+
if (rel.endsWith('[x]')) {
|
|
443
|
+
const narrowedTo = narrowedChoiceType(field);
|
|
444
|
+
if (!narrowedTo)
|
|
445
|
+
return;
|
|
446
|
+
rel = choicePropertyName(rel.slice(0, -'[x]'.length), narrowedTo);
|
|
447
|
+
}
|
|
448
|
+
rel = rel.replace(/\[x\]/g, '');
|
|
160
449
|
const pathParts = rel.split('.');
|
|
161
450
|
if (pathParts.length > 1 && pathParts[0] !== 'meta') {
|
|
451
|
+
// A fixed value below the root was dropped entirely. cdex pins
|
|
452
|
+
// Task.owner.reference to '#practitionerrole', and HL7 reported the
|
|
453
|
+
// mismatch while we reported nothing.
|
|
454
|
+
emitNestedFixedValue(result, rel, field, arrayFieldPaths, profileUrl, baseResourceType);
|
|
162
455
|
return;
|
|
163
456
|
}
|
|
164
457
|
const humanRel = rel || relFull;
|
|
@@ -227,6 +520,20 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
|
|
|
227
520
|
// Skip if ANY segment has a slice qualifier — the constraint is
|
|
228
521
|
// slice-scoped (e.g. name:Geburtsname.given) and must not be emitted
|
|
229
522
|
// as a global prohibition.
|
|
523
|
+
//
|
|
524
|
+
// TODO(parity): this correctly avoids a global prohibition but emits nothing
|
|
525
|
+
// at all, so slice-scoped max=0 goes unchecked. A complex extension sets its
|
|
526
|
+
// own value[x] to max=0 — sub-extensions or a value, never both — and we miss
|
|
527
|
+
// it: on DTRStdQuestionnaire, HL7 reports "Extension.value[x]: max allowed =
|
|
528
|
+
// 0, but found 1" and "Extension.extension: minimum required = 2, but only
|
|
529
|
+
// found 0" for item.extension:contextExpression while we report neither.
|
|
530
|
+
//
|
|
531
|
+
// Fixing it needs a slice-scoped emission, not a relaxed guard: iterate the
|
|
532
|
+
// extensions matching the slice's url and assert the absence of any value*
|
|
533
|
+
// property. It cannot name one property, because a forbidden choice narrows
|
|
534
|
+
// to no type — which is why the choice resolution elsewhere in this file
|
|
535
|
+
// skips it too. Removing this guard instead would reintroduce the regression
|
|
536
|
+
// where AuditEvent.agent:user.requestor was demanded of every agent.
|
|
230
537
|
const segments = field.elementId.split('.');
|
|
231
538
|
if (segments.some(seg => seg.includes(':')))
|
|
232
539
|
return false;
|
|
@@ -260,7 +567,7 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
|
|
|
260
567
|
if (pathParts.length === 1) {
|
|
261
568
|
result.push(`
|
|
262
569
|
// Prohibited field: ${resourceType}.${rel} (max=0)
|
|
263
|
-
if (
|
|
570
|
+
if (at(resource, '${rel}') !== undefined) {
|
|
264
571
|
errors.push("${resourceType}.${rel}: max allowed = 0, but found 1");
|
|
265
572
|
}`);
|
|
266
573
|
}
|
|
@@ -277,7 +584,7 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
|
|
|
277
584
|
: `'${leafField}'`;
|
|
278
585
|
result.push(`
|
|
279
586
|
// Prohibited field: ${fullPath} (max=0)
|
|
280
|
-
for (const _el of arr(
|
|
587
|
+
for (const _el of arr(at(resource, '${arrayParent}'))) {
|
|
281
588
|
if (at(_el, ${atPath}) !== undefined) {
|
|
282
589
|
errors.push("${fullPath}: max allowed = 0, but found 1");
|
|
283
590
|
}
|
|
@@ -315,8 +622,11 @@ export function buildPrimitiveFormatValidations(fields) {
|
|
|
315
622
|
const propName = `${rel}${suffix}`;
|
|
316
623
|
result.push(`
|
|
317
624
|
// ${fhirType} format validation for ${propName} (choice type)
|
|
318
|
-
|
|
319
|
-
|
|
625
|
+
{
|
|
626
|
+
const _fmtValue = at(resource, '${propName}');
|
|
627
|
+
if (typeof _fmtValue === 'string' && !${info.regex}.test(_fmtValue)) {
|
|
628
|
+
errors.push("Not a valid ${info.label} format: '" + _fmtValue + "'");
|
|
629
|
+
}
|
|
320
630
|
}`);
|
|
321
631
|
}
|
|
322
632
|
}
|
|
@@ -327,8 +637,11 @@ export function buildPrimitiveFormatValidations(fields) {
|
|
|
327
637
|
const { regex, label } = PRIMITIVE_FORMAT_REGEX[fhirType];
|
|
328
638
|
result.push(`
|
|
329
639
|
// ${fhirType} format validation for ${rel}
|
|
330
|
-
|
|
331
|
-
|
|
640
|
+
{
|
|
641
|
+
const _fmtValue = at(resource, '${rel}');
|
|
642
|
+
if (typeof _fmtValue === 'string' && !${regex}.test(_fmtValue)) {
|
|
643
|
+
errors.push("Not a valid ${label} format: '" + _fmtValue + "'");
|
|
644
|
+
}
|
|
332
645
|
}`);
|
|
333
646
|
}
|
|
334
647
|
});
|
|
@@ -341,6 +654,16 @@ const FHIR_PRIMITIVE_TYPES = new Set([
|
|
|
341
654
|
'time', 'instant', 'code', 'id', 'oid', 'markdown', 'base64Binary',
|
|
342
655
|
'uuid', 'positiveInt', 'unsignedInt', 'integer64', 'xhtml',
|
|
343
656
|
]);
|
|
657
|
+
/**
|
|
658
|
+
* FHIR primitives that serialize as a JSON number.
|
|
659
|
+
*
|
|
660
|
+
* integer64 is deliberately absent: R5 serializes it as a *string* to survive
|
|
661
|
+
* JavaScript's 53-bit safe integer range, so requiring a number would reject
|
|
662
|
+
* conformant data.
|
|
663
|
+
*/
|
|
664
|
+
const NUMERIC_PRIMITIVES = new Set([
|
|
665
|
+
'decimal', 'integer', 'positiveInt', 'unsignedInt',
|
|
666
|
+
]);
|
|
344
667
|
/** Check whether a FHIR type serializes as a JSON primitive (string/number/bool). */
|
|
345
668
|
function isFhirPrimitive(fieldType) {
|
|
346
669
|
if (FHIR_PRIMITIVE_TYPES.has(fieldType))
|
|
@@ -366,8 +689,72 @@ const CONTAINER_TYPES = new Set([
|
|
|
366
689
|
*
|
|
367
690
|
* Only top-level (depth-1) non-sliced fields are checked.
|
|
368
691
|
*/
|
|
369
|
-
|
|
692
|
+
/**
|
|
693
|
+
* A numeric or boolean primitive below a repeating element must still hold the right
|
|
694
|
+
* JSON kind.
|
|
695
|
+
*
|
|
696
|
+
* The flat pass deliberately skips any path with an array ancestor, because a path
|
|
697
|
+
* walk cannot descend one — so `ingredient.strength.numerator.value` went unchecked
|
|
698
|
+
* and HL7 reported "The value 'Example value' is not a valid decimal" alone. This
|
|
699
|
+
* emits the loop that pass declined to guess at.
|
|
700
|
+
*
|
|
701
|
+
* Both of HL7's headings are pushed: the value wording classifies as `unclassified`
|
|
702
|
+
* and the parse wording as `constraint`, so emitting only the first leaves
|
|
703
|
+
* `constraint` looking like an HL7-only finding.
|
|
704
|
+
*/
|
|
705
|
+
function buildArrayNestedPrimitiveKindValidations(fields, arrayFieldPaths, result) {
|
|
706
|
+
const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
|
|
707
|
+
const seen = new Set();
|
|
708
|
+
for (const field of fields) {
|
|
709
|
+
if (field.sliceName || field.isArray)
|
|
710
|
+
continue;
|
|
711
|
+
const rel = (field.name || '').replace(/^[^.]+\./, '');
|
|
712
|
+
if (!rel || !rel.includes('.') || rel.includes('[x]') || seen.has(rel))
|
|
713
|
+
continue;
|
|
714
|
+
const fieldType = field.type || field.baseTypeCode;
|
|
715
|
+
const jsonKind = NUMERIC_PRIMITIVES.has(fieldType || '') ? 'number'
|
|
716
|
+
: fieldType === 'boolean' ? 'boolean'
|
|
717
|
+
: undefined;
|
|
718
|
+
if (!jsonKind)
|
|
719
|
+
continue;
|
|
720
|
+
const segments = rel.split('.');
|
|
721
|
+
const parents = segments.slice(0, -1);
|
|
722
|
+
const leaf = segments[segments.length - 1];
|
|
723
|
+
// The flat pass already covers a path with no array on the way down.
|
|
724
|
+
if (!parents.some((_p, i) => isArrayPath(parents.slice(0, i + 1).join('.'))))
|
|
725
|
+
continue;
|
|
726
|
+
seen.add(rel);
|
|
727
|
+
let expr = 'resource';
|
|
728
|
+
const open = [];
|
|
729
|
+
let indent = ' ';
|
|
730
|
+
parents.forEach((segment, i) => {
|
|
731
|
+
if (isArrayPath(parents.slice(0, i + 1).join('.'))) {
|
|
732
|
+
const loopVar = `_pkEl${i}`;
|
|
733
|
+
open.push(`${indent}for (const ${loopVar} of arr(at(${expr}, '${segment}'))) {`);
|
|
734
|
+
indent += ' ';
|
|
735
|
+
expr = loopVar;
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
expr = `at(${expr}, '${segment}')`;
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
const closes = open.map((_l, i) => ' '.repeat(open.length - i + 1) + '}').join('\n');
|
|
742
|
+
const body = `${indent}const _pkValue = at(${expr}, '${leaf}');
|
|
743
|
+
${indent}if (_pkValue !== undefined && _pkValue !== null && typeof _pkValue !== '${jsonKind}') {
|
|
744
|
+
${indent} errors.push("The value '" + String(_pkValue) + "' is not a valid ${fieldType}");
|
|
745
|
+
${indent} errors.push("Error parsing JSON: the primitive value must be a ${jsonKind}");
|
|
746
|
+
${indent}}`;
|
|
747
|
+
result.push(`
|
|
748
|
+
// Structural type check: ${rel} must be a JSON ${jsonKind} (${fieldType})
|
|
749
|
+
{
|
|
750
|
+
${[...open, body, closes].filter(Boolean).join('\n')}
|
|
751
|
+
}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
export function buildStructuralTypeValidations(fields, arrayFieldPaths) {
|
|
370
755
|
const result = [];
|
|
756
|
+
buildNestedObjectTypeValidations(fields, arrayFieldPaths, result);
|
|
757
|
+
buildArrayNestedPrimitiveKindValidations(fields, arrayFieldPaths, result);
|
|
371
758
|
for (const field of fields) {
|
|
372
759
|
// Skip sliced fields — handled by slice validator
|
|
373
760
|
if (field.sliceName)
|
|
@@ -380,27 +767,86 @@ export function buildStructuralTypeValidations(fields, _arrayFieldPaths) {
|
|
|
380
767
|
if (fullPath.includes('[x]'))
|
|
381
768
|
continue;
|
|
382
769
|
const rel = fullPath.replace(/^[^.]+\./, '');
|
|
383
|
-
if (!rel
|
|
384
|
-
continue;
|
|
770
|
+
if (!rel)
|
|
771
|
+
continue;
|
|
772
|
+
// A nested primitive is still worth checking, and only the primitive kind check
|
|
773
|
+
// below handles one: HL7 reports "The value 'Example value' is not a valid
|
|
774
|
+
// decimal" on Medication.amount.numerator.value, three levels down, where we
|
|
775
|
+
// reported nothing. The array and object checks stay top-level only, since both
|
|
776
|
+
// would need iteration to descend correctly.
|
|
777
|
+
const isNested = rel.includes('.');
|
|
778
|
+
if (isNested) {
|
|
779
|
+
// Any array on the way down needs a loop, not a path walk. Skipped rather than
|
|
780
|
+
// guessed at, so a repeating ancestor never yields a check against the array
|
|
781
|
+
// object itself.
|
|
782
|
+
// arrayFieldPaths is keyed on the *relative* path. Building the key from the
|
|
783
|
+
// full path never matched, so Bundle.entry.search.score was emitted as
|
|
784
|
+
// at(resource, 'entry', 'search', 'score') — a walk into an array, which
|
|
785
|
+
// yields undefined and checks nothing.
|
|
786
|
+
const segments = rel.split('.');
|
|
787
|
+
const hasArrayAncestor = segments.some((_seg, i) => arrayFieldPaths.has(segments.slice(0, i + 1).join('.')));
|
|
788
|
+
if (hasArrayAncestor)
|
|
789
|
+
continue;
|
|
790
|
+
if (!NUMERIC_PRIMITIVES.has(field.type || '') && field.type !== 'boolean')
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
385
793
|
const fieldType = field.type || field.baseTypeCode;
|
|
386
794
|
if (!fieldType)
|
|
387
795
|
continue;
|
|
388
796
|
// ── Array structural check ─────────────────────────────────────────
|
|
389
|
-
if (field.isArray) {
|
|
797
|
+
if (field.isArray && !isNested) {
|
|
390
798
|
result.push(`
|
|
391
799
|
// Structural type check: ${rel} must be a JSON Array
|
|
392
|
-
|
|
393
|
-
|
|
800
|
+
{
|
|
801
|
+
const _stValue = at(resource, '${rel}');
|
|
802
|
+
if (_stValue !== undefined && _stValue !== null && !Array.isArray(_stValue)) {
|
|
803
|
+
const _stType = at(resource, 'resourceType');
|
|
804
|
+
errors.push("The property ${rel} must be a JSON Array, not an Object (at " + (typeof _stType === 'string' ? _stType : "Resource") + ")");
|
|
805
|
+
}
|
|
806
|
+
}`);
|
|
807
|
+
}
|
|
808
|
+
// ── Primitive JSON kind check ──────────────────────────────────────
|
|
809
|
+
// A numeric or boolean FHIR primitive must not arrive as a string. We checked
|
|
810
|
+
// that complex types are objects but never that a decimal holds a number, so
|
|
811
|
+
// HL7 reported "The value 'Example value' is not a valid decimal" and "the
|
|
812
|
+
// primitive value must be a number" where we said nothing.
|
|
813
|
+
//
|
|
814
|
+
// Both headings, as HL7 reports them: the value wording classifies as
|
|
815
|
+
// `unclassified` and the parse wording as `constraint`, so emitting only the
|
|
816
|
+
// first left `constraint` attributed to HL7 alone for a defect we had detected.
|
|
817
|
+
//
|
|
818
|
+
// Only the kinds JSON distinguishes are checked. Date, code and the rest all
|
|
819
|
+
// serialize as strings, and their formats already have dedicated checks
|
|
820
|
+
// elsewhere; asserting `typeof === 'string'` on them would add nothing.
|
|
821
|
+
const jsonKind = NUMERIC_PRIMITIVES.has(fieldType) ? 'number'
|
|
822
|
+
: fieldType === 'boolean' ? 'boolean'
|
|
823
|
+
: undefined;
|
|
824
|
+
if (jsonKind && !field.isArray) {
|
|
825
|
+
// Segments, not a dotted key: at() walks its varargs, so at(resource, 'a.b')
|
|
826
|
+
// would look for a property literally named "a.b" and always find nothing.
|
|
827
|
+
const accessorArgs = rel.split('.').map(seg => `'${seg}'`).join(', ');
|
|
828
|
+
result.push(`
|
|
829
|
+
// Structural type check: ${rel} must be a JSON ${jsonKind} (${fieldType})
|
|
830
|
+
{
|
|
831
|
+
const _stValue = at(resource, ${accessorArgs});
|
|
832
|
+
if (_stValue !== undefined && _stValue !== null && typeof _stValue !== '${jsonKind}') {
|
|
833
|
+
errors.push("The value '" + String(_stValue) + "' is not a valid ${fieldType}");
|
|
834
|
+
errors.push("Error parsing JSON: the primitive value must be a ${jsonKind}");
|
|
835
|
+
}
|
|
394
836
|
}`);
|
|
395
837
|
}
|
|
396
838
|
// ── Complex type as Object check ───────────────────────────────────
|
|
397
|
-
if (!isFhirPrimitive(fieldType) && !CONTAINER_TYPES.has(fieldType)) {
|
|
839
|
+
if (!isNested && !isFhirPrimitive(fieldType) && !CONTAINER_TYPES.has(fieldType)) {
|
|
398
840
|
// Complex types must be objects when present — not primitive strings/numbers
|
|
399
841
|
if (!field.isArray) {
|
|
400
842
|
result.push(`
|
|
401
843
|
// Structural type check: ${rel} must be an Object, not a Primitive
|
|
402
|
-
|
|
403
|
-
|
|
844
|
+
{
|
|
845
|
+
const _stValue = at(resource, '${rel}');
|
|
846
|
+
if (_stValue !== undefined && _stValue !== null && typeof _stValue !== 'object') {
|
|
847
|
+
const _stType = at(resource, 'resourceType');
|
|
848
|
+
errors.push("The property ${rel} must be an Object, not a Primitive property (at " + (typeof _stType === 'string' ? _stType : "Resource") + ".${rel})");
|
|
849
|
+
}
|
|
404
850
|
}`);
|
|
405
851
|
}
|
|
406
852
|
}
|