diff --git a/clients/internal/manual/client.go b/clients/internal/manual/client.go index 845dbdd65..3a8ef3025 100644 --- a/clients/internal/manual/client.go +++ b/clients/internal/manual/client.go @@ -108,7 +108,73 @@ func (m ManualClient) SyncAllBundle(db models.DatabaseRepository, bundleFile *os continue } } + case pkg.DocumentTypeFhirList: + list401Data := fhir401.List{} + err := base.UnmarshalJson(bundleFile, &list401Data) + if err != nil { + return summary, fmt.Errorf("an error occurred while parsing 4.0.1 list: %w", err) + } + + //find the encounter reference + if list401Data.Encounter == nil || list401Data.Encounter.Reference == nil || *list401Data.Encounter.Reference == "" { + return summary, fmt.Errorf("list does not contain an encounter reference") + } + var encounterSourceId, encounterResourceType, encounterResourceId string + encounterSourceId, encounterResourceType, encounterResourceId, err = pkg.ParseReferenceUri(list401Data.Encounter.Reference) + if err != nil { + referenceParts := strings.Split(*list401Data.Encounter.Reference, "/") + + if len(referenceParts) == 2 { + encounterSourceId = m.SourceCredential.GetSourceId() + encounterResourceType = referenceParts[0] + encounterResourceId = referenceParts[1] + } else { + return summary, fmt.Errorf("an error occurred while parsing encounter reference: %w", err) + } + } + + //loop though all the contained resources, and process them + for ndx, contained := range list401Data.Contained { + //attempt to parse the FHIR Resource + apiModel, err := wrapFhir401JsonToRawResource(contained) + if err != nil { + syncErrors[fmt.Sprintf("contained index: %d", ndx)] = err + continue + } + + rawResourceList = append(rawResourceList, *apiModel) + err = client.ProcessResource(db, *apiModel, lookupResourceReferences, internalFragmentReferenceLookup, &summary) + if err != nil { + syncErrors[apiModel.SourceResourceType] = err + continue + } + } + + //loop though all the entries (references), and process them + for ndx, entry := range list401Data.Entry { + //parse the Reference + + entrySourceId, entryResourceType, entryResourceId, err := pkg.ParseReferenceUri(entry.Item.Reference) + if err != nil { + syncErrors[fmt.Sprintf("reference (%s)", ndx)] = err + continue + } + err = db.UpsertRawResourceAssociation(m.Context, encounterSourceId, encounterResourceType, encounterResourceId, entrySourceId, entryResourceType, entryResourceId) + if err != nil { + syncErrors[fmt.Sprintf("reference association (%s)", ndx)] = err + continue + } + } + + summary.TotalResources = len(rawResourceList) + m.Logger.Infof("Completed document processing: %d resources", summary.TotalResources) + + if len(syncErrors) > 0 { + //TODO: ignore errors. + m.Logger.Errorf("%d error(s) occurred during sync. \n %v", len(syncErrors), syncErrors) + } + return summary, nil case pkg.DocumentTypeFhirNDJSON: d := json.NewDecoder(bundleFile) counter := 0 @@ -125,32 +191,22 @@ func (m ManualClient) SyncAllBundle(db models.DatabaseRepository, bundleFile *os } } - resourceObj, err := fhir401utils.MapToResource(resource, false) + //attempt to parse the FHIR Resource + apiModel, err := wrapFhir401JsonToRawResource(resource) if err != nil { syncErrors[fmt.Sprintf("index: %d", counter)] = err continue } - resourceObjTyped := resourceObj.(models.ResourceInterface) - resourceType, resourceId := resourceObjTyped.ResourceRef() - if resourceId == nil { - syncErrors[fmt.Sprintf("%s (index: %d)", resourceType, counter)] = fmt.Errorf("resource ID is nil, skipping") - continue - } - - apiModel := models.RawResourceFhir{ - SourceResourceID: *resourceId, - SourceResourceType: resourceType, - ResourceRaw: resource, - } - rawResourceList = append(rawResourceList, apiModel) - err = client.ProcessResource(db, apiModel, lookupResourceReferences, internalFragmentReferenceLookup, &summary) + rawResourceList = append(rawResourceList, *apiModel) + err = client.ProcessResource(db, *apiModel, lookupResourceReferences, internalFragmentReferenceLookup, &summary) if err != nil { syncErrors[apiModel.SourceResourceType] = err continue } counter += 1 + } } summary.TotalResources = len(rawResourceList) @@ -192,3 +248,23 @@ func GetSourceClientManual(env pkg.FastenLighthouseEnvType, ctx context.Context, func cleanPatientIdPrefix(patientId string) string { return strings.TrimLeft(patientId, "Patient/") } + +func wrapFhir401JsonToRawResource(jsonResource json.RawMessage) (*models.RawResourceFhir, error) { + resourceObj, err := fhir401utils.MapToResource(jsonResource, false) + if err != nil { + return nil, err + } + + resourceObjTyped := resourceObj.(models.ResourceInterface) + resourceType, resourceId := resourceObjTyped.ResourceRef() + if resourceId == nil { + return nil, fmt.Errorf("resource ID is nil, skipping") + } + + return &models.RawResourceFhir{ + SourceResourceID: *resourceId, + SourceResourceType: resourceType, + ResourceRaw: jsonResource, + }, nil + +} diff --git a/clients/internal/manual/testdata/fixtures/401-R4/list/family-history.json b/clients/internal/manual/testdata/fixtures/401-R4/list/family-history.json new file mode 100644 index 000000000..267bba798 --- /dev/null +++ b/clients/internal/manual/testdata/fixtures/401-R4/list/family-history.json @@ -0,0 +1,298 @@ +{ + "resourceType": "List", + "id": "genetic", + "text": { + "status": "generated", + "div": "

Generated Narrative with Details

id: genetic

contained: , , , , , , ,

status: current

mode: snapshot

code: History of family member diseases (Details : {LOINC code '8670-2' = 'History of family member diseases', given as 'History of family member diseases'})

subject: Peter Patient

entry

item: id: 1; status: completed; name: Dave; father (Details : {http://terminology.hl7.org/CodeSystem/v3-RoleCode code 'FTH' = 'father', given as 'father'})

entry

item: id: 2; status: completed; maternal grandfather (Details : {http://terminology.hl7.org/CodeSystem/v3-RoleCode code 'MGRFTH' = 'maternal grandfather', given as 'maternal grandfather'})

entry

item: id: 3; status: completed; mother (Details : {http://terminology.hl7.org/CodeSystem/v3-RoleCode code 'MTH' = 'mother', given as 'mother'})

entry

item: id: 4; status: completed; paternal grandmother (Details : {http://terminology.hl7.org/CodeSystem/v3-RoleCode code 'PGRMTH' = 'paternal grandmother', given as 'paternal grandmother'})

entry

item: id: 5; status: completed; name: Eve; paternal aunt (Details : {http://terminology.hl7.org/CodeSystem/v3-RoleCode code 'PAUNT' = 'paternal aunt', given as 'paternal aunt'})

entry

item: id: 6; status: completed; maternal uncle (Details : {http://terminology.hl7.org/CodeSystem/v3-RoleCode code 'MUNCLE' = 'maternal uncle', given as 'maternal uncle'})

entry

item: id: 7; status: completed; natural sister (Details : {http://terminology.hl7.org/CodeSystem/v3-RoleCode code 'NSIS' = 'natural sister', given as 'natural sister'})

entry

item: id: 8; status: completed; name: Alice; maternal cousin (Details : {http://terminology.hl7.org/CodeSystem/v3-RoleCode code 'MCOUSN' = 'maternal cousin', given as 'maternal cousin'})

entry

item: Family history of cancer of colon

" + }, + "contained": [ + { + "resourceType": "FamilyMemberHistory", + "id": "1", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/family-member-history-genetics-parent", + "extension": [ + { + "url": "type", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "FTH", + "display": "father" + } + ] + } + }, + { + "url": "reference", + "valueReference": { + "reference": "#2", + "display": "maternal grandfather" + } + } + ] + } + ], + "status": "completed", + "patient": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "name": "Dave", + "relationship": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "FTH", + "display": "father" + } + ] + } + }, + { + "resourceType": "FamilyMemberHistory", + "id": "2", + "status": "completed", + "patient": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "relationship": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "MGRFTH", + "display": "maternal grandfather" + } + ] + } + }, + { + "resourceType": "FamilyMemberHistory", + "id": "3", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/family-member-history-genetics-parent", + "extension": [ + { + "url": "type", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "MTH", + "display": "mother" + } + ] + } + }, + { + "url": "reference", + "valueReference": { + "reference": "#2", + "display": "maternal grandfather" + } + } + ] + } + ], + "status": "completed", + "patient": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "relationship": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "MTH", + "display": "mother" + } + ] + } + }, + { + "resourceType": "FamilyMemberHistory", + "id": "4", + "status": "completed", + "patient": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "relationship": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "PGRMTH", + "display": "paternal grandmother" + } + ] + } + }, + { + "resourceType": "FamilyMemberHistory", + "id": "5", + "status": "completed", + "patient": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "name": "Eve", + "relationship": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "PAUNT", + "display": "paternal aunt" + } + ] + } + }, + { + "resourceType": "FamilyMemberHistory", + "id": "6", + "status": "completed", + "patient": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "relationship": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "MUNCLE", + "display": "maternal uncle" + } + ] + } + }, + { + "resourceType": "FamilyMemberHistory", + "id": "7", + "status": "completed", + "patient": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "relationship": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "NSIS", + "display": "natural sister" + } + ] + } + }, + { + "resourceType": "FamilyMemberHistory", + "id": "8", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/family-member-history-genetics-parent", + "extension": [ + { + "url": "type", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "MTH", + "display": "mother" + } + ] + } + }, + { + "url": "reference", + "valueReference": { + "reference": "#2", + "display": "maternal grandfather" + } + } + ] + } + ], + "status": "completed", + "patient": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "name": "Alice", + "relationship": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "MCOUSN", + "display": "maternal cousin" + } + ] + } + } + ], + "status": "current", + "mode": "snapshot", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "8670-2", + "display": "History of family member diseases" + } + ] + }, + "subject": { + "reference": "Patient/example", + "display": "Peter Patient" + }, + "entry": [ + { + "item": { + "reference": "#1" + } + }, + { + "item": { + "reference": "#2" + } + }, + { + "item": { + "reference": "#3" + } + }, + { + "item": { + "reference": "#4" + } + }, + { + "item": { + "reference": "#5" + } + }, + { + "item": { + "reference": "#6" + } + }, + { + "item": { + "reference": "#7" + } + }, + { + "item": { + "reference": "#8" + } + }, + { + "item": { + "reference": "Condition/family-history", + "display": "Family history of cancer of colon" + } + } + ] +} diff --git a/clients/internal/manual/utils.go b/clients/internal/manual/utils.go index 596b664f7..60a4988fb 100644 --- a/clients/internal/manual/utils.go +++ b/clients/internal/manual/utils.go @@ -87,6 +87,8 @@ func GetFileDocumentType(file *os.File) (pkg.DocumentType, error) { primaryResourceType, _ := parsedResource.ResourceRef() if primaryResourceType == "Bundle" { return pkg.DocumentTypeFhirBundle, nil + } else if primaryResourceType == "List" { + return pkg.DocumentTypeFhirList, nil } else { return pkg.DocumentType("unknown"), fmt.Errorf("unknown FHIR Resource collection type: %s", primaryResourceType) } diff --git a/clients/internal/manual/utils_test.go b/clients/internal/manual/utils_test.go index 91548e18e..8e280c18a 100644 --- a/clients/internal/manual/utils_test.go +++ b/clients/internal/manual/utils_test.go @@ -15,6 +15,7 @@ func TestGetFileDocumentType(t *testing.T) { expectedDocumentType pkg.DocumentType // expected result }{ {"testdata/fixtures/401-R4/bundle/synthea_Tania553_Harris789_545c2380-b77f-4919-ab5d-0f615f877250.json", true, pkg.DocumentTypeFhirBundle}, + {"testdata/fixtures/401-R4/list/family-history.json", true, pkg.DocumentTypeFhirList}, {"testdata/fixtures/401-R4/ccda/MaryGrant-ClinicalSummary.xml", true, pkg.DocumentTypeCCDA}, {"testdata/fixtures/401-R4/international-patient-summary/IPS-bundle-01.json", true, pkg.DocumentTypeFhirBundle}, {"testdata/fixtures/401-R4/phr-ndjson-jsonl/JohnDoe.phr", true, pkg.DocumentTypeFhirNDJSON}, diff --git a/clients/models/database_repository.go b/clients/models/database_repository.go index eb6dfc882..6ba74bd0e 100644 --- a/clients/models/database_repository.go +++ b/clients/models/database_repository.go @@ -7,5 +7,14 @@ import ( //go:generate mockgen -source=database_repository.go -destination=mock/mock_database_repository.go type DatabaseRepository interface { UpsertRawResource(ctx context.Context, sourceCredentials SourceCredential, rawResource RawResourceFhir) (bool, error) + UpsertRawResourceAssociation( + ctx context.Context, + sourceId string, + sourceResourceType string, + sourceResourceId string, + targetSourceId string, + targetResourceType string, + targetResourceId string, + ) error BackgroundJobCheckpoint(ctx context.Context, checkpointData map[string]interface{}, errorData map[string]interface{}) } diff --git a/clients/models/mock/mock_database_repository.go b/clients/models/mock/mock_database_repository.go index f90bb57f9..680912f1d 100644 --- a/clients/models/mock/mock_database_repository.go +++ b/clients/models/mock/mock_database_repository.go @@ -61,3 +61,17 @@ func (mr *MockDatabaseRepositoryMockRecorder) UpsertRawResource(ctx, sourceCrede mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertRawResource", reflect.TypeOf((*MockDatabaseRepository)(nil).UpsertRawResource), ctx, sourceCredentials, rawResource) } + +// UpsertRawResourceAssociation mocks base method. +func (m *MockDatabaseRepository) UpsertRawResourceAssociation(ctx context.Context, sourceId, sourceResourceType, sourceResourceId, targetSourceId, targetResourceType, targetResourceId string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertRawResourceAssociation", ctx, sourceId, sourceResourceType, sourceResourceId, targetSourceId, targetResourceType, targetResourceId) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertRawResourceAssociation indicates an expected call of UpsertRawResourceAssociation. +func (mr *MockDatabaseRepositoryMockRecorder) UpsertRawResourceAssociation(ctx, sourceId, sourceResourceType, sourceResourceId, targetSourceId, targetResourceType, targetResourceId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertRawResourceAssociation", reflect.TypeOf((*MockDatabaseRepository)(nil).UpsertRawResourceAssociation), ctx, sourceId, sourceResourceType, sourceResourceId, targetSourceId, targetResourceType, targetResourceId) +} diff --git a/clients/models/mock/mock_source_credential.go b/clients/models/mock/mock_source_credential.go index 3223eac1d..cf5200063 100644 --- a/clients/models/mock/mock_source_credential.go +++ b/clients/models/mock/mock_source_credential.go @@ -146,6 +146,20 @@ func (mr *MockSourceCredentialMockRecorder) GetRefreshToken() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRefreshToken", reflect.TypeOf((*MockSourceCredential)(nil).GetRefreshToken)) } +// GetSourceId mocks base method. +func (m *MockSourceCredential) GetSourceId() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSourceId") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetSourceId indicates an expected call of GetSourceId. +func (mr *MockSourceCredentialMockRecorder) GetSourceId() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSourceId", reflect.TypeOf((*MockSourceCredential)(nil).GetSourceId)) +} + // GetSourceType mocks base method. func (m *MockSourceCredential) GetSourceType() pkg.SourceType { m.ctrl.T.Helper() diff --git a/clients/models/source_credential.go b/clients/models/source_credential.go index 222325678..e987209ec 100644 --- a/clients/models/source_credential.go +++ b/clients/models/source_credential.go @@ -2,9 +2,11 @@ package models import "github.com/fastenhealth/fasten-sources/pkg" +// this is actually an interface to a pointer receiver +// //go:generate mockgen -source=source_credential.go -destination=mock/mock_source_credential.go -//this is actually an interface to a pointer receiver type SourceCredential interface { + GetSourceId() string GetSourceType() pkg.SourceType GetClientId() string GetPatientId() string diff --git a/pkg/document_type.go b/pkg/document_type.go index 36a41fe8a..706a17822 100644 --- a/pkg/document_type.go +++ b/pkg/document_type.go @@ -5,5 +5,6 @@ type DocumentType string const ( DocumentTypeCCDA DocumentType = "CCDA" DocumentTypeFhirBundle DocumentType = "FHIR_BUNDLE" + DocumentTypeFhirList DocumentType = "FHIR_LIST" DocumentTypeFhirNDJSON DocumentType = "NDJSON" ) diff --git a/pkg/resource_uri.go b/pkg/resource_uri.go new file mode 100644 index 000000000..2f6ca2dd3 --- /dev/null +++ b/pkg/resource_uri.go @@ -0,0 +1,32 @@ +package pkg + +import ( + "fmt" + "strings" +) + +const FASTENHEALTH_URN_PREFIX = "urn:fastenhealth-fhir:" + +func ParseReferenceUri(referenceUri *string) (string, string, string, error) { + if referenceUri == nil || *referenceUri == "" { + return "", "", "", fmt.Errorf("reference cannot be empty nil") + } + if strings.HasPrefix(*referenceUri, FASTENHEALTH_URN_PREFIX) { + //parse referenceUri into sourceId, resourceType, resourceId + originalReference := strings.TrimPrefix(*referenceUri, FASTENHEALTH_URN_PREFIX) + urnParts := strings.Split(originalReference, ":") + if len(urnParts) != 2 { + return "", "", "", fmt.Errorf("invalid reference (%s), must have 2 parts", *referenceUri) + } + sourceId := urnParts[0] + resourceParts := strings.Split(urnParts[1], "/") + if len(resourceParts) != 2 { + return "", "", "", fmt.Errorf("invalid resource id (%s), must have 2 parts", *referenceUri) + } + + return sourceId, resourceParts[0], resourceParts[1], nil + + } else { + return "", "", "", fmt.Errorf("invalid reference (%s), must start with `%s`", *referenceUri, FASTENHEALTH_URN_PREFIX) + } +}