Fix bundle resolution rules to conform to the specification in version R4+

This commit is contained in:
Grahame Grieve 2023-11-13 07:38:31 +11:00
parent c128f3b61d
commit 4c6a318749
7 changed files with 104 additions and 30 deletions

View File

@ -43,6 +43,7 @@ public class Constants {
public final static String VERSION_MM = "5.0";
public final static String DATE = "Thu, Mar 23, 2023 19:59+1100";
public final static String URI_REGEX = "((http|https):\\/\\/([A-Za-z0-9\\\\\\.\\:\\%\\$\\-]*\\/)*?)?(Account|ActivityDefinition|ActorDefinition|AdministrableProductDefinition|AdverseEvent|AllergyIntolerance|Appointment|AppointmentResponse|ArtifactAssessment|AuditEvent|Basic|Binary|BiologicallyDerivedProduct|BiologicallyDerivedProductDispense|BodyStructure|Bundle|CapabilityStatement|CarePlan|CareTeam|ChargeItem|ChargeItemDefinition|Citation|Claim|ClaimResponse|ClinicalImpression|ClinicalUseDefinition|CodeSystem|Communication|CommunicationRequest|CompartmentDefinition|Composition|ConceptMap|Condition|ConditionDefinition|Consent|Contract|Coverage|CoverageEligibilityRequest|CoverageEligibilityResponse|DetectedIssue|Device|DeviceAssociation|DeviceDefinition|DeviceDispense|DeviceMetric|DeviceRequest|DeviceUsage|DiagnosticReport|DocumentReference|Encounter|EncounterHistory|Endpoint|EnrollmentRequest|EnrollmentResponse|EpisodeOfCare|EventDefinition|Evidence|EvidenceReport|EvidenceVariable|ExampleScenario|ExplanationOfBenefit|FamilyMemberHistory|Flag|FormularyItem|GenomicStudy|Goal|GraphDefinition|Group|GuidanceResponse|HealthcareService|ImagingSelection|ImagingStudy|Immunization|ImmunizationEvaluation|ImmunizationRecommendation|ImplementationGuide|Ingredient|InsurancePlan|InventoryItem|InventoryReport|Invoice|Library|Linkage|List|Location|ManufacturedItemDefinition|Measure|MeasureReport|Medication|MedicationAdministration|MedicationDispense|MedicationKnowledge|MedicationRequest|MedicationStatement|MedicinalProductDefinition|MessageDefinition|MessageHeader|MolecularSequence|NamingSystem|NutritionIntake|NutritionOrder|NutritionProduct|Observation|ObservationDefinition|OperationDefinition|OperationOutcome|Organization|OrganizationAffiliation|PackagedProductDefinition|Parameters|Patient|PaymentNotice|PaymentReconciliation|Permission|Person|PlanDefinition|Practitioner|PractitionerRole|Procedure|Provenance|Questionnaire|QuestionnaireResponse|RegulatedAuthorization|RelatedPerson|RequestOrchestration|Requirements|ResearchStudy|ResearchSubject|RiskAssessment|Schedule|SearchParameter|ServiceRequest|Slot|Specimen|SpecimenDefinition|StructureDefinition|StructureMap|Subscription|SubscriptionStatus|SubscriptionTopic|Substance|SubstanceDefinition|SubstanceNucleicAcid|SubstancePolymer|SubstanceProtein|SubstanceReferenceInformation|SubstanceSourceMaterial|SupplyDelivery|SupplyRequest|Task|TerminologyCapabilities|TestPlan|TestReport|TestScript|Transport|ValueSet|VerificationResult|VisionPrescription)\\/[A-Za-z0-9\\-\\.]{1,64}(\\/_history\\/[A-Za-z0-9\\-\\.]{1,64})?";
public final static String URI_REGEX_XVER = "((http|https):\\/\\/([A-Za-z0-9\\\\\\.\\:\\%\\$\\-]*\\/)*?)?($$)\\/[A-Za-z0-9\\-\\.]{1,64}(\\/_history\\/[A-Za-z0-9\\-\\.]{1,64})?";
public static final String NS_FHIR_ROOT = "http://hl7.org/fhir";
public static final String NS_CDA_ROOT = "http://hl7.org/cda/stds/core";
}

View File

@ -28,6 +28,7 @@ public class I18nConstants {
public static final String BUNDLE_BUNDLE_ENTRY_NOPROFILE_EXPL = "Bundle_BUNDLE_Entry_NoProfile_EXPL";
public static final String BUNDLE_BUNDLE_ENTRY_NOPROFILE_TYPE = "Bundle_BUNDLE_Entry_NoProfile_TYPE";
public static final String BUNDLE_BUNDLE_ENTRY_NOTFOUND = "Bundle_BUNDLE_Entry_NotFound";
public static final String BUNDLE_BUNDLE_ENTRY_NOTFOUND_APPARENT = "BUNDLE_BUNDLE_ENTRY_NOTFOUND_APPARENT";
public static final String BUNDLE_BUNDLE_ENTRY_ORPHAN_DOCUMENT = "Bundle_BUNDLE_Entry_Orphan_DOCUMENT";
public static final String BUNDLE_BUNDLE_ENTRY_ORPHAN_MESSAGE = "Bundle_BUNDLE_Entry_Orphan_MESSAGE";
public static final String BUNDLE_BUNDLE_ENTRY_REVERSE_R4 = "BUNDLE_BUNDLE_ENTRY_REVERSE_R4";
@ -1022,6 +1023,9 @@ public class I18nConstants {
public static final String FHIRPATH_ARITHMETIC_UNIT = "FHIRPATH_ARITHMETIC_UNIT";
public static final String FHIRPATH_ARITHMETIC_PLUS = "FHIRPATH_ARITHMETIC_PLUS";
public static final String FHIRPATH_ARITHMETIC_MINUS = "FHIRPATH_ARITHMETIC_MINUS";
public static final String BUNDLE_ENTRY_URL_MATCHES_TYPE_ID = "BUNDLE_ENTRY_URL_MATCHES_TYPE_ID";
public static final String BUNDLE_ENTRY_URL_MATCHES_NO_ID = "BUNDLE_ENTRY_URL_MATCHES_NO_ID";
public static final String BUNDLE_ENTRY_URL_ABSOLUTE = "BUNDLE_ENTRY_URL_ABSOLUTE";
}

View File

@ -12,6 +12,7 @@ Bundle_BUNDLE_Entry_NoProfile_TYPE = No profile found for {0} resource of type '
Bundle_BUNDLE_Entry_NoProfile_EXPL = Specified profile {2} not found for {0} resource of type ''{0}''
Bundle_BUNDLE_Entry_NO_LOGICAL_EXPL = Specified logical model {1} not found for resource ''Binary/{0}''
Bundle_BUNDLE_Entry_NotFound = Can''t find ''{0}'' in the bundle ({1})
BUNDLE_BUNDLE_ENTRY_NOTFOUND_APPARENT = Can''t find ''{0}'' in the bundle ({1}). Note that there is a resource in the bundle with the same type and id, but it does not match because of the fullUrl based rules around matching relative resources
Bundle_BUNDLE_Entry_Orphan_MESSAGE = Entry {0} isn''t reachable by traversing links (forward or backward) from the MessageHeader, so its presence should be reviewed (is it needed to process the message?)
Bundle_BUNDLE_Entry_Orphan_DOCUMENT = Entry {0} isn''t reachable by traversing links (forward or backward) from the Composition
BUNDLE_BUNDLE_ENTRY_REVERSE_R4 = Entry {0} isn''t reachable by traversing forwards from the Composition. Only Provenance is approved to be used this way (R4 section 3.3.1)
@ -1079,3 +1080,6 @@ FHIRPATH_ARITHMETIC_QTY = Error in date arithmetic: attempt to add a definite qu
FHIRPATH_ARITHMETIC_UNIT = Error in date arithmetic: unrecognized time unit {0}
FHIRPATH_ARITHMETIC_PLUS = Error in date arithmetic: Unable to add type {0} to {1}
FHIRPATH_ARITHMETIC_MINUS = Error in date arithmetic: Unable to subtract type {0} to {1}
BUNDLE_ENTRY_URL_MATCHES_NO_ID = The fullUrl ''{0}'' looks like a RESTful server URL, but the resource has no id
BUNDLE_ENTRY_URL_MATCHES_TYPE_ID = The fullUrl ''{0}'' looks like a RESTful server URL, so it must end with the correct type and id (/{1}/{2})
BUNDLE_ENTRY_URL_ABSOLUTE = The fullUrl must be an absolute URL (not ''{0}'')

View File

@ -55,6 +55,7 @@ import org.hl7.fhir.r5.formats.IParser.OutputStyle;
import org.hl7.fhir.r5.model.Base;
import org.hl7.fhir.r5.model.CanonicalResource;
import org.hl7.fhir.r5.model.Coding;
import org.hl7.fhir.r5.model.Constants;
import org.hl7.fhir.r5.model.DomainResource;
import org.hl7.fhir.r5.model.Resource;
import org.hl7.fhir.r5.model.StructureDefinition;
@ -68,14 +69,17 @@ import org.hl7.fhir.r5.utils.XVerExtensionManager.XVerExtensionStatus;
import org.hl7.fhir.r5.utils.validation.IResourceValidator;
import org.hl7.fhir.r5.utils.validation.ValidationContextCarrier.IValidationContextResourceLoader;
import org.hl7.fhir.r5.utils.validation.constants.BestPracticeWarningLevel;
import org.hl7.fhir.utilities.CommaSeparatedStringBuilder;
import org.hl7.fhir.utilities.FhirPublication;
import org.hl7.fhir.utilities.StandardsStatus;
import org.hl7.fhir.utilities.Utilities;
import org.hl7.fhir.utilities.VersionUtilities;
import org.hl7.fhir.utilities.i18n.I18nConstants;
import org.hl7.fhir.utilities.validation.ValidationMessage;
import org.hl7.fhir.utilities.validation.ValidationMessage.IssueSeverity;
import org.hl7.fhir.utilities.validation.ValidationMessage.IssueType;
import org.hl7.fhir.utilities.validation.ValidationMessage.Source;
import org.hl7.fhir.validation.BaseValidator.ElementMatch;
import org.hl7.fhir.validation.cli.utils.ValidationLevel;
import org.hl7.fhir.validation.instance.utils.IndexedElement;
import org.hl7.fhir.validation.instance.utils.NodeStack;
@ -83,6 +87,25 @@ import org.hl7.fhir.validation.instance.utils.NodeStack;
public class BaseValidator implements IValidationContextResourceLoader {
public class ElementMatch {
private Element element;
private boolean valid;
protected ElementMatch(Element element, boolean valid) {
super();
this.element = element;
this.valid = valid;
}
public Element getElement() {
return element;
}
public boolean isValid() {
return valid;
}
}
public class BooleanHolder {
private boolean value = true;
@ -180,6 +203,7 @@ public class BaseValidator implements IValidationContextResourceLoader {
this.xverManager = new XVerExtensionManager(context);
}
this.debug = debug;
urlRegex = Constants.URI_REGEX_XVER.replace("$$", CommaSeparatedStringBuilder.join("|", context.getResourceNames()));
}
public BaseValidator(BaseValidator parent) {
@ -199,6 +223,7 @@ public class BaseValidator implements IValidationContextResourceLoader {
this.warnOnDraftOrExperimental = parent.warnOnDraftOrExperimental;
this.statusWarnings = parent.statusWarnings;
this.bpWarnings = parent.bpWarnings;
this.urlRegex = parent.urlRegex;
}
private boolean doingLevel(IssueSeverity error) {
@ -241,6 +266,8 @@ public class BaseValidator implements IValidationContextResourceLoader {
*/
private Map<String, ValidationControl> validationControl = new HashMap<>();
protected String urlRegex;
/**
* Test a rule and add a {@link IssueSeverity#FATAL} validation message if the validation fails
*
@ -998,12 +1025,15 @@ public class BaseValidator implements IValidationContextResourceLoader {
return null;
}
protected Element resolveInBundle(Element bundle, List<Element> entries, String ref, String fullUrl, String type, String id) {
protected ElementMatch resolveInBundle(Element bundle, List<Element> entries, String ref, String fullUrl, String type, String id) {
@SuppressWarnings("unchecked")
Map<String, Element> map = (Map<String, Element>) bundle.getUserData("validator.entrymap");
Map<String, Element> relMap = (Map<String, Element>) bundle.getUserData("validator.entrymapR");
if (map == null) {
map = new HashMap<>();
bundle.setUserData("validator.entrymap", map);
relMap = new HashMap<>();
bundle.setUserData("validator.entrymapR", relMap);
for (Element entry : entries) {
String fu = entry.getNamedChildValue(FULL_URL);
map.put(fu, entry);
@ -1011,14 +1041,25 @@ public class BaseValidator implements IValidationContextResourceLoader {
if (resource != null) {
String et = resource.getType();
String eid = resource.getNamedChildValue(ID);
if (eid != null) {
if (VersionUtilities.isR4Plus(context.getVersion())) {
relMap.put(et+"/"+eid, entry);
} else {
map.put(et+"/"+eid, entry);
}
}
}
}
}
if (Utilities.isAbsoluteUrl(ref)) {
// if the reference is absolute, then you resolve by fullUrl. No other thinking is required.
return map.get(ref);
Element e = map.get(ref);
if (e == null) {
return null;
} else {
return new ElementMatch(e, true);
}
// for (Element entry : entries) {
// String fu = entry.getNamedChildValue(FULL_URL);
// if (ref.equals(fu))
@ -1028,9 +1069,10 @@ public class BaseValidator implements IValidationContextResourceLoader {
} else {
// split into base, type, and id
String u = null;
if (fullUrl != null && fullUrl.endsWith(type + "/" + id))
if (fullUrl != null && fullUrl.matches(urlRegex) && fullUrl.endsWith(type + "/" + id)) {
// fullUrl = complex
u = fullUrl.substring(0, fullUrl.length() - (type + "/" + id).length()) + ref;
}
// u = fullUrl.substring((type+"/"+id).length())+ref;
String[] parts = ref.split("\\/");
if (parts.length >= 2) {
@ -1040,7 +1082,14 @@ public class BaseValidator implements IValidationContextResourceLoader {
if (res == null) {
res = map.get(t+"/"+i);
}
return res;
if (res == null && relMap.containsKey(t+"/"+i)) {
res = relMap.get(t+"/"+i);
return new ElementMatch(res, false);
} else if (res == null) {
return null;
} else {
return new ElementMatch(res, true);
}
// for (Element entry : entries) {
// String fu = entry.getNamedChildValue(FULL_URL);
// if (fu != null && fu.equals(u))

View File

@ -5381,9 +5381,9 @@ public class InstanceValidator extends BaseValidator implements IResourceValidat
if (!Utilities.noString(ref)) {
for (Element bundle : bundles) {
List<Element> entries = bundle.getChildren(ENTRY);
Element tgt = resolveInBundle(bundle, entries, ref, fu, resource.fhirType(), resource.getIdBase());
if (tgt != null) {
element.setUserData("validator.bundle.resolution", tgt.getNamedChild(RESOURCE));
ElementMatch tgt = resolveInBundle(bundle, entries, ref, fu, resource.fhirType(), resource.getIdBase());
if (tgt != null && tgt.isValid()) {
element.setUserData("validator.bundle.resolution", tgt.getElement().getNamedChild(RESOURCE));
return;
}
}

View File

@ -58,7 +58,7 @@ public class BundleValidator extends BaseValidator {
if (entries.size() == 0) {
ok = rule(errors, NO_RULE_DATE, IssueType.INVALID, stack.getLiteralPath(), !(type.equals(DOCUMENT) || type.equals(MESSAGE)), I18nConstants.BUNDLE_BUNDLE_ENTRY_NOFIRST) && ok;
} else {
// Get the first entry, the MessageHeader
// Get the first entry, the MessageHeader or Document
Element firstEntry = entries.get(0);
// Get the stack of the first entry
NodeStack firstStack = stack.push(firstEntry, 1, null, null);
@ -75,16 +75,14 @@ public class BundleValidator extends BaseValidator {
ok = handleSpecialCaseForLastUpdated(bundle, errors, stack) && ok;
}
ok = checkAllInterlinked(errors, entries, stack, bundle, false) && ok;
}
if (type.equals(MESSAGE)) {
} else if (type.equals(MESSAGE)) {
Element resource = firstEntry.getNamedChild(RESOURCE);
if (rule(errors, NO_RULE_DATE, IssueType.INVALID, firstEntry.line(), firstEntry.col(), stack.addToLiteralPath(ENTRY, PATH_ARG), resource != null, I18nConstants.BUNDLE_BUNDLE_ENTRY_NOFIRSTRESOURCE)) {
String id = resource.getNamedChildValue(ID);
ok = validateMessage(errors, entries, resource, firstStack.push(resource, -1, null, null), fullUrl, id) && ok;
ok = checkAllInterlinked(errors, entries, stack, bundle, true) && ok;
}
}
if (type.equals(SEARCHSET)) {
} else if (type.equals(SEARCHSET)) {
ok = checkSearchSet(errors, bundle, entries, stack) && ok;
}
// We do not yet have rules requiring that the id and fullUrl match when dealing with messaging Bundles
@ -101,8 +99,24 @@ public class BundleValidator extends BaseValidator {
String fullUrl = entry.getNamedChildValue(FULL_URL);
String url = getCanonicalURLForEntry(entry);
String id = getIdForEntry(entry);
String rtype = getTypeForEntry(entry);
if (!Utilities.noString(fullUrl)) {
if (Utilities.isAbsoluteUrl(fullUrl)) {
if (rtype != null && fullUrl.matches(urlRegex)) {
if (rule(errors, "2023-11-13", IssueType.INVALID, entry.line(), entry.col(), stack.addToLiteralPath(ENTRY, PATH_ARG), id != null, I18nConstants.BUNDLE_ENTRY_URL_MATCHES_NO_ID, fullUrl)) {
ok = rule(errors, "2023-11-13", IssueType.INVALID, entry.line(), entry.col(), stack.addToLiteralPath(ENTRY, PATH_ARG), fullUrl.endsWith("/"+rtype+"/"+id), I18nConstants.BUNDLE_ENTRY_URL_MATCHES_TYPE_ID, fullUrl, rtype, id) && ok;
} else {
ok = false;
}
}
} else {
ok = false;
rule(errors, "2023-11-13", IssueType.INVALID, entry.line(), entry.col(), stack.addToLiteralPath(ENTRY, PATH_ARG), false, I18nConstants.BUNDLE_ENTRY_URL_ABSOLUTE, fullUrl);
}
}
if (url != null) {
if (!(!url.equals(fullUrl) || (url.matches(uriRegexForVersion()) && url.endsWith("/" + id))) && !isV3orV2Url(url))
if (!(!url.equals(fullUrl) || (url.matches(urlRegex) && url.endsWith("/" + id))) && !isV3orV2Url(url))
ok = rule(errors, NO_RULE_DATE, IssueType.INVALID, entry.line(), entry.col(), stack.addToLiteralPath(ENTRY, PATH_ARG), false, I18nConstants.BUNDLE_BUNDLE_ENTRY_MISMATCHIDURL, url, fullUrl, id) && ok;
ok = rule(errors, NO_RULE_DATE, IssueType.INVALID, entry.line(), entry.col(), stack.addToLiteralPath(ENTRY, PATH_ARG), !url.equals(fullUrl) || serverBase == null || (url.equals(Utilities.pathURL(serverBase, entry.getNamedChild(RESOURCE).fhirType(), id))), I18nConstants.BUNDLE_BUNDLE_ENTRY_CANONICAL, url, fullUrl) && ok;
}
@ -111,8 +125,7 @@ public class BundleValidator extends BaseValidator {
ok = rule(errors, NO_RULE_DATE, IssueType.INVALID, entry.line(), entry.col(), estack.getLiteralPath(), fullUrlOptional || fullUrl != null, I18nConstants.BUNDLE_BUNDLE_ENTRY_FULLURL_REQUIRED) && ok;
}
// check bundle profile requests
if (entry.hasChild(RESOURCE)) {
String rtype = entry.getNamedChild(RESOURCE).fhirType();
if (rtype != null) {
int rcount = counter.containsKey(rtype) ? counter.get(rtype)+1 : 0;
counter.put(rtype, rcount);
Element res = entry.getNamedChild(RESOURCE);
@ -548,9 +561,12 @@ public class BundleValidator extends BaseValidator {
}
if (ref != null && !Utilities.noString(reference) && !reference.startsWith("#")) {
Element target = resolveInBundle(bundle, entries, reference, fullUrl, type, id);
return rule(errors, NO_RULE_DATE, IssueType.INVALID, ref.line(), ref.col(), stack.addToLiteralPath("reference"), target != null,
I18nConstants.BUNDLE_BUNDLE_ENTRY_NOTFOUND, reference, name);
ElementMatch target = resolveInBundle(bundle, entries, reference, fullUrl, type, id);
if (target == null) {
return rule(errors, NO_RULE_DATE, IssueType.INVALID, ref.line(), ref.col(), stack.addToLiteralPath("reference"), false, I18nConstants.BUNDLE_BUNDLE_ENTRY_NOTFOUND, reference, name);
} else {
return rule(errors, NO_RULE_DATE, IssueType.INVALID, ref.line(), ref.col(), stack.addToLiteralPath("reference"), target.isValid(), I18nConstants.BUNDLE_BUNDLE_ENTRY_NOTFOUND_APPARENT, reference, name);
}
}
return true;
}
@ -594,9 +610,9 @@ public class BundleValidator extends BaseValidator {
for (EntrySummary e : entryList) {
Set<String> references = findReferences(e.getEntry());
for (String ref : references) {
Element tgt = resolveInBundle(bundle, entries, ref, e.getEntry().getChildValue(FULL_URL), e.getResource().fhirType(), e.getResource().getIdBase());
if (tgt != null) {
EntrySummary t = entryForTarget(entryList, tgt);
ElementMatch tgt = resolveInBundle(bundle, entries, ref, e.getEntry().getChildValue(FULL_URL), e.getResource().fhirType(), e.getResource().getIdBase());
if (tgt != null && tgt.isValid()) {
EntrySummary t = entryForTarget(entryList, tgt.getElement());
if (t != null ) {
if (t != e) {
// System.out.println("Entry "+e.getIndex()+" refers to "+t.getIndex()+" by ref '"+ref+"'");
@ -689,13 +705,6 @@ public class BundleValidator extends BaseValidator {
return Utilities.existsInList(fhirType, "Provenance");
}
private String uriRegexForVersion() {
if (VersionUtilities.isR3Ver(context.getVersion()))
return URI_REGEX3;
else
return Constants.URI_REGEX;
}
private String getCanonicalURLForEntry(Element entry) {
Element e = entry.getNamedChild(RESOURCE);
if (e == null)
@ -710,6 +719,13 @@ public class BundleValidator extends BaseValidator {
return e.getNamedChildValue(ID);
}
private String getTypeForEntry(Element entry) {
Element e = entry.getNamedChild(RESOURCE);
if (e == null)
return null;
return e.fhirType();
}
/**
* Check each resource entry to ensure that the entry's fullURL includes the resource's id
* value. Adds an ERROR ValidationMessge to errors List for a given entry if it references

View File

@ -20,7 +20,7 @@
<properties>
<guava_version>32.0.1-jre</guava_version>
<hapi_fhir_version>6.4.1</hapi_fhir_version>
<validator_test_case_version>1.4.16</validator_test_case_version>
<validator_test_case_version>1.4.17-SNAPSHOT</validator_test_case_version>
<jackson_version>2.15.2</jackson_version>
<junit_jupiter_version>5.9.2</junit_jupiter_version>
<junit_platform_launcher_version>1.8.2</junit_platform_launcher_version>