Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/fhirtypes/StructureDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
}
99 changes: 99 additions & 0 deletions test/fhirtypes/StructureDefinition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down