diff --git a/src/fhirtypes/StructureDefinition.ts b/src/fhirtypes/StructureDefinition.ts index 5f99be289..482969863 100644 --- a/src/fhirtypes/StructureDefinition.ts +++ b/src/fhirtypes/StructureDefinition.ts @@ -534,6 +534,17 @@ export class StructureDefinition { }); } + // FHIR forbids an Extension from having both a `value[x]` and child `extension`. + // That combination can arise when a caret rule mutates a slot that inherits a + // `value[x]`-bearing extension from the parent (e.g. authoring an obligation over + // an inherited `elementdefinition-translatable` on a primitive element): the new + // URL and children get written but the inherited `value[x]` is left behind. Strip + // those orphaned `value[x]` fields here so the emitted JSON is always valid. + if (j.snapshot) { + j.snapshot.element.forEach(sanitizeElementDefinitionExtensions); + } + j.differential.element.forEach(sanitizeElementDefinitionExtensions); + // If the StructureDefinition is in progress, we want to persist that in the JSON so that when // the Fisher retrieves it from a package and converts to JSON, the inProgress state will be // preserved. But do NOT persist it when it is false. @@ -1090,3 +1101,34 @@ const PROPS_AND_UNDERPROPS: string[] = PROPS.reduce((collect: string[], prop) => collect.push(prop, `_${prop}`); return collect; }, []); + + +/** + * Recursively strip any `value[x]` field from Extension nodes that also carry a child + * `extension` array. FHIR forbids an Extension from having both a value and child + * extensions; the combination silently appears when caret rules mutate a slot that + * inherits a value-bearing extension (e.g. an inherited `elementdefinition-translatable` + * with `valueBoolean: true` on a primitive element being repurposed into an `obligation` + * extension with child `code`/`actor` extensions). The child extensions encode the + * caller's intent, so we drop the orphaned value[x] rather than the children. + */ +function sanitizeElementDefinitionExtensions(elem: any): void { + if (elem == null || typeof elem !== 'object') return; + cleanExtensionArray(elem.extension); + cleanExtensionArray(elem.modifierExtension); +} + +function cleanExtensionArray(arr: any): void { + if (!Array.isArray(arr)) return; + for (const ext of arr) { + if (ext == null || typeof ext !== 'object') continue; + if (Array.isArray(ext.extension) && ext.extension.length > 0) { + for (const key of Object.keys(ext)) { + if (/^value[A-Z]/.test(key)) delete ext[key]; + } + cleanExtensionArray(ext.extension); + } else if (Array.isArray(ext.extension)) { + cleanExtensionArray(ext.extension); + } + } +} diff --git a/test/fhirtypes/StructureDefinition.test.ts b/test/fhirtypes/StructureDefinition.test.ts index c0c409a9c..ba3184246 100644 --- a/test/fhirtypes/StructureDefinition.test.ts +++ b/test/fhirtypes/StructureDefinition.test.ts @@ -239,6 +239,105 @@ describe('StructureDefinition', () => { expect(json.snapshot).toBeUndefined(); }); + it('should strip orphaned value[x] from Extensions that also have child extension entries', () => { + // FHIR forbids an Extension from having both a value[x] and child extensions. + // This combination silently arises when a caret rule mutates a slot that + // inherits a value-bearing extension from the parent (e.g. authoring an + // obligation over an inherited `elementdefinition-translatable` on a primitive + // element). The child extensions encode the caller's intent, so toJSON should + // drop the orphaned value[x] field rather than the children. + const code = usCoreObservation.elements.find(e => e.id === 'Observation.code'); + // Mutate an inherited extension slot into a compound (value+children) shape, + // mimicking what happens when caret rules repurpose an inherited primitive + // extension into an obligation extension with sub-extensions. + code.extension = [ + { + url: 'http://hl7.org/fhir/StructureDefinition/obligation', + valueBoolean: true, + extension: [ + { url: 'code', valueCode: 'SHALL:populate-if-known' }, + { + url: 'actor', + valueCanonical: 'http://hl7.org/fhir/uv/ips/ActorDefinition/Creator' + } + ] + }, + // A well-formed value-bearing extension (no children) must be preserved. + { + url: 'http://hl7.org/fhir/StructureDefinition/elementdefinition-translatable', + valueBoolean: true + }, + // A well-formed complex extension (children, no value) must be preserved. + { + url: 'http://hl7.org/fhir/StructureDefinition/obligation', + extension: [ + { url: 'code', valueCode: 'SHOULD:display' }, + { + url: 'actor', + valueCanonical: 'http://hl7.org/fhir/uv/ips/ActorDefinition/Consumer' + } + ] + } + ]; + const json = usCoreObservation.toJSON(); + const diffCode = json.differential.element.find((e: any) => e.id === 'Observation.code'); + expect(diffCode.extension).toEqual([ + { + url: 'http://hl7.org/fhir/StructureDefinition/obligation', + extension: [ + { url: 'code', valueCode: 'SHALL:populate-if-known' }, + { + url: 'actor', + valueCanonical: 'http://hl7.org/fhir/uv/ips/ActorDefinition/Creator' + } + ] + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/elementdefinition-translatable', + valueBoolean: true + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/obligation', + extension: [ + { url: 'code', valueCode: 'SHOULD:display' }, + { + url: 'actor', + valueCanonical: 'http://hl7.org/fhir/uv/ips/ActorDefinition/Consumer' + } + ] + } + ]); + // Snapshot should be sanitized the same way. + const snapCode = json.snapshot.element.find((e: any) => e.id === 'Observation.code'); + expect(snapCode.extension).toEqual(diffCode.extension); + }); + + it('should strip orphaned value[x] from nested child Extensions when value+children co-occur deep in a tree', () => { + // The sanitizer must recurse into child extension arrays so that a malformed + // grandchild Extension is also cleaned, not just the top-level one. + const code = usCoreObservation.elements.find(e => e.id === 'Observation.code'); + code.extension = [ + { + url: 'http://hl7.org/fhir/StructureDefinition/obligation', + extension: [ + // Grandchild is itself an invalid Extension (value + children). + { + url: 'code', + valueCode: 'SHALL:populate-if-known', + extension: [{ url: 'reason', valueString: 'placeholder' }] + } + ] + } + ]; + const json = usCoreObservation.toJSON(); + const diffCode = json.differential.element.find((e: any) => e.id === 'Observation.code'); + expect(diffCode.extension[0].extension[0]).toEqual({ + url: 'code', + extension: [{ url: 'reason', valueString: 'placeholder' }] + }); + expect(diffCode.extension[0].extension[0].valueCode).toBeUndefined(); + }); + it('should properly serialize snapshot and differential for constrained choice type with constraints on specific choices', () => { // constrain value[x] to only a Quantity or string and give each its own short const valueX = usCoreObservation.elements.find(