diff --git a/packages/fhir-eswatini/package.json b/packages/fhir-eswatini/package.json index 42eeb503bd..10ab792a03 100644 --- a/packages/fhir-eswatini/package.json +++ b/packages/fhir-eswatini/package.json @@ -15,7 +15,7 @@ "type": "module", "fhir": { "specUrl": "http://172.209.216.154/definitions.json.zip", - "adaptorGeneratedDate": "2026-06-02T15:23:39.432Z", + "adaptorGeneratedDate": "2026-06-05T10:42:23.868Z", "generatorVersion": "0.7.9", "options": { "base": "fhir-4" diff --git a/packages/fhir-eswatini/src/builders.ts b/packages/fhir-eswatini/src/builders.ts index d341f22443..b3e41ae0f6 100644 --- a/packages/fhir-eswatini/src/builders.ts +++ b/packages/fhir-eswatini/src/builders.ts @@ -573,6 +573,7 @@ export function organization(type: any, props?: any) { * @public * @function * @param {object} props - Properties to apply to the resource (includes common and custom properties). + * @param {} [props._birthDate] - undefined * @param {boolean} [props.active] - Whether this patient's record is in active use * @param {Address} [props.address] - An address for the individual * @param {date} [props.birthDate] - Date of birth: YYYY-MM-DD diff --git a/packages/fhir-eswatini/src/datatypes.ts b/packages/fhir-eswatini/src/datatypes.ts index 8c610b36c2..4abe30dd66 100644 --- a/packages/fhir-eswatini/src/datatypes.ts +++ b/packages/fhir-eswatini/src/datatypes.ts @@ -7,6 +7,7 @@ export const { coding, composite, concept, + ensureConceptText, ext, extendSystemMap, extendValues, diff --git a/packages/fhir-eswatini/src/profiles/SzPatient.ts b/packages/fhir-eswatini/src/profiles/SzPatient.ts index aa253ef445..2f8d41c233 100644 --- a/packages/fhir-eswatini/src/profiles/SzPatient.ts +++ b/packages/fhir-eswatini/src/profiles/SzPatient.ts @@ -9,6 +9,7 @@ import type { builders as FHIR } from "@openfn/language-fhir-4"; type MaybeArray = T | T[]; export type Patient_SzPatient_Props = { + _birthDate?: any; active?: boolean; address?: FHIR.Address[]; birthDate?: string; @@ -135,6 +136,37 @@ export default function(props: Partial) { } } + { + if (!_.isNil(props._birthDate)) { + if (_.isPlainObject(props._birthDate)) { + resource._birthDate = Object.assign({}, props._birthDate); + } else { + delete resource._birthDate; + resource._birthDate = {}; + + dt.addExtension( + resource._birthDate, + "http://hl7.org/fhir/StructureDefinition/patient-birthTime", + props._birthDate + ); + } + } + + if (!_.isNil(props._birthTime)) { + delete resource._birthTime; + + if (!resource._birthDate) { + resource._birthDate = {}; + } + + dt.addExtension( + resource._birthDate, + "http://hl7.org/fhir/StructureDefinition/patient-birthTime", + props._birthTime + ); + } + } + if (!_.isNil(props.deceased)) { delete resource.deceased; dt.composite(resource, "deceased", props.deceased); diff --git a/packages/fhir-eswatini/test/resources/Patient.test.ts b/packages/fhir-eswatini/test/resources/Patient.test.ts index 2f124d2e58..7431b738c2 100644 --- a/packages/fhir-eswatini/test/resources/Patient.test.ts +++ b/packages/fhir-eswatini/test/resources/Patient.test.ts @@ -439,4 +439,39 @@ describe('SzPatient', () => { valueDateTime: '2025-06-01T10:00:00Z', }); }); + + it('should map _birthDate primitive extension shorthand', () => { + const resource = b.patient('SzPatient', { + birthDate: '10/07/1990', + _birthDate: '2000-01-01T14:35:45-05:00', + }); + + assert.deepEqual(resource.birthDate, '10/07/1990'); + assert.deepEqual(resource._birthDate, { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/patient-birthTime', + valueDateTime: '2000-01-01T14:35:45-05:00', + }, + ], + }); + }); + + it('should map _birthTime shorthand into _birthDate extension', () => { + const resource = b.patient('SzPatient', { + birthDate: '10/07/1990', + _birthTime: '2000-01-01T14:35:45-05:00', + }); + + assert.deepEqual(resource.birthDate, '10/07/1990'); + assert.deepEqual(resource._birthDate, { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/patient-birthTime', + valueDateTime: '2000-01-01T14:35:45-05:00', + }, + ], + }); + assert.equal(resource._birthTime, undefined); + }); }); diff --git a/packages/fhir-eswatini/types/builders.d.ts b/packages/fhir-eswatini/types/builders.d.ts index 381cbeb2d7..f027ecbbb1 100644 --- a/packages/fhir-eswatini/types/builders.d.ts +++ b/packages/fhir-eswatini/types/builders.d.ts @@ -461,6 +461,7 @@ declare type Organization_SzOrganization_Props = { }; declare type Patient_SzPatient_Props = { + _birthDate?: any; active?: boolean; address?: builders.Address[]; birthDate?: string; @@ -676,6 +677,7 @@ declare const cc: (codings: (builders.Coding | [string, string, Omit void; declare const concept: (codings: (builders.Coding | [string, string, Omit?]) | (builders.Coding | [string, string, Omit?])[], extra?: Omit) => builders.CodeableConcept; +declare const ensureConceptText: (concept: any) => void; declare const ext: (url: string, value: any, props?: Omit) => { extension: ({ url: string; @@ -1058,6 +1060,7 @@ declare function organization(props: Organization_SzOrganization_Props): any; * @public * @function * @param {object} props - Properties to apply to the resource (includes common and custom properties). + * @param {} [props._birthDate] - undefined * @param {boolean} [props.active] - Whether this patient's record is in active use * @param {Address} [props.address] - An address for the individual * @param {date} [props.birthDate] - Date of birth: YYYY-MM-DD @@ -1239,5 +1242,5 @@ declare function serviceRequest(type: "SzReferral", props: ServiceRequest_SzRefe declare function specimen(type: "SzLabSpecimen", props: Specimen_SzLabSpecimen_Props): any; declare function specimen(props: Specimen_SzLabSpecimen_Props): any; -export { addExtension, appointment, c, cc, coding, composite, concept, condition, encounter, episodeOfCare, ext, extendSystemMap, extendValues, extension, findExtension, id, identifier, location, lookupValue, mapSystems, mapValues, medication, medicationDispense, medicationRequest, observation, organization, patient, practitioner, procedure, ref, reference, serviceRequest, setSystemMap, setValues, specimen, value }; +export { addExtension, appointment, c, cc, coding, composite, concept, condition, encounter, ensureConceptText, episodeOfCare, ext, extendSystemMap, extendValues, extension, findExtension, id, identifier, location, lookupValue, mapSystems, mapValues, medication, medicationDispense, medicationRequest, observation, organization, patient, practitioner, procedure, ref, reference, serviceRequest, setSystemMap, setValues, specimen, value }; diff --git a/tools/generate-fhir/src/codegen/generate-types.ts b/tools/generate-fhir/src/codegen/generate-types.ts index e3e63083d3..dfeb491573 100644 --- a/tools/generate-fhir/src/codegen/generate-types.ts +++ b/tools/generate-fhir/src/codegen/generate-types.ts @@ -53,6 +53,19 @@ export const generateType = ( continue; } + // Primitive extension props (_birthDate etc) are typed as `any` -- + // their content depends on which extensions are present + if ((s as any).isPrimitiveExtension) { + props.push( + b.tsPropertySignature( + b.identifier(key), + b.tsTypeAnnotation(b.tsAnyKeyword()), + true, + ), + ); + continue; + } + // TODO need to handle this stuff! // let type; // if (s.typeDef) { diff --git a/tools/generate-fhir/src/generate-code.ts b/tools/generate-fhir/src/generate-code.ts index b716d4ac76..dfe6142dee 100644 --- a/tools/generate-fhir/src/generate-code.ts +++ b/tools/generate-fhir/src/generate-code.ts @@ -600,10 +600,136 @@ const mapSimpleProp = (propName: string, mapping: Mapping, schema: Schema) => { return ifPropInInput(propName, [assignProp], elseStatement); }; +const mapPrimitiveTypeDef = (propName: string, schema: Schema) => { + // propName is the underscore prop, eg _birthDate + const primitivePropName = propName; + // Only retrieve extension-backed child props + const primitiveExtensions = Object.entries(schema.typeDef || {}).filter( + ([, spec]: [string, any]) => spec.extension, + ); + const statements: StatementKind[] = []; + + const inputPropRef = (name: string) => safelyRefProp(INPUT_NAME, name); + const resourcePropRef = (name: string) => safelyRefProp(RESOURCE_NAME, name); + const copyPrimitiveMetadata = (name: string) => + b.expressionStatement( + b.assignmentExpression( + '=', + resourcePropRef(name), + b.callExpression( + b.memberExpression(b.identifier('Object'), b.identifier('assign')), + [b.objectExpression([]), inputPropRef(name)], + ), + ), + ); + const createEmptyPrimitiveMetadata = (name: string) => + b.expressionStatement( + b.assignmentExpression('=', resourcePropRef(name), b.objectExpression([])), + ); + const addPrimitiveExtension = ( + targetName: string, + sourceName: string, + url: string, + ) => + b.expressionStatement( + b.callExpression( + b.memberExpression(b.identifier('dt'), b.identifier('addExtension')), + [resourcePropRef(targetName), b.stringLiteral(url), inputPropRef(sourceName)], + ), + ); + + if (primitiveExtensions.length === 1) { + const [[, spec]] = primitiveExtensions as [string, any][]; + statements.push( + ifPropInInput( + primitivePropName, + [ + b.ifStatement( + b.callExpression( + b.memberExpression(b.identifier('_'), b.identifier('isPlainObject')), + [inputPropRef(primitivePropName)], + ), + b.blockStatement([ + // If the caller already passed primitive metadata, preserve it + copyPrimitiveMetadata(primitivePropName), + ]), + b.blockStatement([ + // Otherwise treat the underscore value as shorthand for the single known extension + b.expressionStatement( + b.unaryExpression('delete', resourcePropRef(primitivePropName)), + ), + createEmptyPrimitiveMetadata(primitivePropName), + addPrimitiveExtension( + primitivePropName, + primitivePropName, + spec.extension.url, + ), + ]), + ), + ], + ), + ); + } else { + statements.push( + ifPropInInput( + primitivePropName, + [ + b.ifStatement( + b.callExpression( + b.memberExpression(b.identifier('_'), b.identifier('isPlainObject')), + [inputPropRef(primitivePropName)], + ), + b.blockStatement([ + // Multiple extension choices: only pass through explicit primitive metadata objects + copyPrimitiveMetadata(primitivePropName), + ]), + ), + ], + ), + ); + } + + for (const [key, spec] of primitiveExtensions as [string, any][]) { + // Support shorthand child props like _birthTime and rewrite them into _birthDate.extension[] + const inputPropName = `_${key}`; + statements.push( + ifPropInInput( + inputPropName, + [ + b.expressionStatement( + b.unaryExpression('delete', resourcePropRef(inputPropName)), + ), + b.ifStatement( + b.unaryExpression('!', resourcePropRef(primitivePropName)), + b.blockStatement([ + // Create the primitive metadata container before appending extension shorthand + createEmptyPrimitiveMetadata(primitivePropName), + ]), + ), + addPrimitiveExtension( + primitivePropName, + inputPropName, + spec.extension.url, + ), + ], + ), + ); + } + + return b.blockStatement(statements); +}; + +const isPrimitiveTypeDefParent = (schema: Schema) => + !!(schema as any).isPrimitiveExtension && !!(schema as any).typeDef; + // map a type def (ie, a nested object) property by property // TODO this is designed to handle singleton and array types // The array stuff adds a lot of complication and I need tests on both formats const mapTypeDef = (propName: string, mapping: Mapping, schema: Schema) => { + if (isPrimitiveTypeDefParent(schema)) { + return mapPrimitiveTypeDef(propName, schema); + } + const statements: any[] = []; statements.push( diff --git a/tools/generate-fhir/src/generate-schema.ts b/tools/generate-fhir/src/generate-schema.ts index 55074fb926..563c720c49 100644 --- a/tools/generate-fhir/src/generate-schema.ts +++ b/tools/generate-fhir/src/generate-schema.ts @@ -343,6 +343,7 @@ async function parseProp( data, ) { let [parent, prop] = path.split('.'); + const isExtensionPath = prop === 'extension'; // TODO skip if multiple dots if (/\[x\]/.test(prop)) { @@ -366,7 +367,16 @@ async function parseProp( if (schema.props[parent]) { const def: PropDef = {}; - if (!data.type || schema.props[parent].type.includes('date')) { + // Keep primitive props + const isExtensionChild = isExtensionPath; + const hasSlice = !!data.sliceName; + const parentTypes = schema.props[parent].type || []; + const isPrimitiveParent = + !schema.props[parent].typeDef && + parentTypes.length > 0 && + parentTypes.every(type => type[0] === type[0]?.toLowerCase()); + + if (!data.type || (isPrimitiveParent && !(isExtensionChild && hasSlice))) { return; } @@ -384,7 +394,8 @@ async function parseProp( type.profile.length && type.profile[0].match(/\/StructureDefinition/) ) { - const typeId = type.profile[0].split('/').at(-1); + const extensionUrl = type.profile[0].split('|')[0]; + const typeId = extensionUrl.split('/').at(-1); const spec = fullSpec[typeId]; if (spec) { @@ -396,7 +407,11 @@ async function parseProp( // look for extension.value[x] in the spec }; } else { - console.log('WARNING: spec not found for ', typeId); + // Some extension profiles are not in the downloaded spec + // The profile URL is still enough for codegen + def.extension = { + url: extensionUrl, + }; } } else { simpleType = typeDefs[type.code] || type.code; @@ -431,8 +446,19 @@ async function parseProp( // } if (Object.keys(def).length) { - schema.props[parent].typeDef ??= {}; - schema.props[parent].typeDef[prop] = def; + if (isPrimitiveParent && isExtensionChild) { + // primitive extension slices go on a top-level _parent prop, eg _birthDate + const underscoreProp = `_${parent}`; + schema.props[underscoreProp] ??= { + type: [], + isPrimitiveExtension: true, + typeDef: {}, + }; + schema.props[underscoreProp].typeDef[prop] = def; + } else { + schema.props[parent].typeDef ??= {}; + schema.props[parent].typeDef[prop] = def; + } } } } diff --git a/tools/generate-fhir/test/generate-code.test.ts b/tools/generate-fhir/test/generate-code.test.ts index cf86d70235..3e615f45ab 100644 --- a/tools/generate-fhir/test/generate-code.test.ts +++ b/tools/generate-fhir/test/generate-code.test.ts @@ -119,7 +119,7 @@ run('calls dt.identifier for Identifier type', t => { t.is(dt.identifier.calls, 1); }); -run('builds single reference', t => { +test.serial('builds single reference', t => { const profile = { x: { type: ['Reference'] }, }; @@ -177,6 +177,79 @@ run('builds typeDef with nested extension', t => { t.is(dt.addExtension.calls, 1); }); +run('builds primitive sibling extension from underscored slice input', t => { + const profile = { + birthDate: { + type: ['date'], + isArray: false, + }, + _birthDate: { + type: [], + isArray: false, + isPrimitiveExtension: true, + typeDef: { + birthTime: { + extension: { + url: 'http://hl7.org/fhir/StructureDefinition/patient-birthTime', + }, + type: 'dateTime', + }, + }, + }, + }; + const schema = generateBuilder('Patient', profile); + const builder = compileBuilder(schema); + const result = builder({ + birthDate: '10/07/1990', + _birthTime: '10am', + }); + + t.is(result.birthDate, '10/07/1990'); + t.is(result._birthTime, undefined); + t.is( + result._birthDate.extension[0].url, + 'http://hl7.org/fhir/StructureDefinition/patient-birthTime' + ); + t.is(result._birthDate.extension[0].value, '10am'); + t.is(dt.addExtension.calls, 1); +}); + +run('builds primitive sibling extension from underscored parent shorthand', t => { + const profile = { + birthDate: { + type: ['date'], + isArray: false, + }, + _birthDate: { + type: [], + isArray: false, + isPrimitiveExtension: true, + typeDef: { + text: { + extension: { + url: 'http://example.org/fhir/StructureDefinition/text', + }, + type: 'string', + }, + }, + }, + }; + const schema = generateBuilder('Patient', profile); + const builder = compileBuilder(schema); + const result = builder({ + birthDate: '10/07/1990', + _birthDate: '10 july', + }); + + t.is(result.birthDate, '10/07/1990'); + t.is( + result._birthDate.extension[0].url, + 'http://example.org/fhir/StructureDefinition/text' + ); + t.is(result._birthDate.extension[0].value, '10 july'); + t.is(dt.addExtension.calls, 1); +}); + run('skips nil properties', t => { const profile = { x: { type: ['Reference'] },