diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/6040-fixing-resource-validation.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/6040-fixing-resource-validation.yaml new file mode 100644 index 00000000000..bcb5f0fc30d --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/6040-fixing-resource-validation.yaml @@ -0,0 +1,8 @@ +--- +type: fix +issue: 6040 +title: "The `meta.profile` element on resources was not being respected as + canonical (ie, allowing for a version to be appended, `http://example.com/StructureDefinition/abc|1.0.0`), + and was thus being ignored during validation. + This has been fixed. +" diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4ValidateTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4ValidateTest.java index 0167dca32f2..1597b816bea 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4ValidateTest.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4ValidateTest.java @@ -1,7 +1,5 @@ package ca.uhn.fhir.jpa.dao.r4; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.assertFalse; import ca.uhn.fhir.context.support.IValidationSupport; import ca.uhn.fhir.context.support.ValidationSupportContext; import ca.uhn.fhir.context.support.ValueSetExpansionOptions; @@ -20,6 +18,7 @@ import ca.uhn.fhir.jpa.term.custom.CustomTerminologySet; import ca.uhn.fhir.jpa.test.BaseJpaR4Test; import ca.uhn.fhir.jpa.validation.JpaValidationSupportChain; import ca.uhn.fhir.jpa.validation.ValidationSettings; +import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.parser.LenientErrorHandler; import ca.uhn.fhir.parser.StrictErrorHandler; import ca.uhn.fhir.rest.api.EncodingEnum; @@ -33,6 +32,8 @@ import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; import ca.uhn.fhir.util.OperationOutcomeUtil; import ca.uhn.fhir.util.StopWatch; import ca.uhn.fhir.validation.IValidatorModule; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; import org.apache.commons.io.IOUtils; import org.hl7.fhir.common.hapi.validation.support.InMemoryTerminologyServerValidationSupport; import org.hl7.fhir.common.hapi.validation.support.UnknownCodeSystemWarningValidationSupport; @@ -40,13 +41,46 @@ import org.hl7.fhir.common.hapi.validation.validator.FhirInstanceValidator; import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; -import org.hl7.fhir.r4.model.*; +import org.hl7.fhir.r4.model.AllergyIntolerance; +import org.hl7.fhir.r4.model.Binary; +import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; +import org.hl7.fhir.r4.model.CanonicalType; +import org.hl7.fhir.r4.model.CapabilityStatement; +import org.hl7.fhir.r4.model.CodeSystem; +import org.hl7.fhir.r4.model.CodeType; +import org.hl7.fhir.r4.model.CodeableConcept; +import org.hl7.fhir.r4.model.Coding; +import org.hl7.fhir.r4.model.Condition; +import org.hl7.fhir.r4.model.DateTimeType; +import org.hl7.fhir.r4.model.ElementDefinition; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Group; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.IntegerType; +import org.hl7.fhir.r4.model.Location; +import org.hl7.fhir.r4.model.Narrative; +import org.hl7.fhir.r4.model.Observation; import org.hl7.fhir.r4.model.Observation.ObservationStatus; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Organization; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.Practitioner; +import org.hl7.fhir.r4.model.Quantity; +import org.hl7.fhir.r4.model.Questionnaire; +import org.hl7.fhir.r4.model.QuestionnaireResponse; +import org.hl7.fhir.r4.model.Reference; +import org.hl7.fhir.r4.model.SearchParameter; +import org.hl7.fhir.r4.model.StringType; +import org.hl7.fhir.r4.model.StructureDefinition; +import org.hl7.fhir.r4.model.UriType; +import org.hl7.fhir.r4.model.ValueSet; import org.hl7.fhir.r5.utils.validation.constants.BestPracticeWarningLevel; import org.hl7.fhir.r5.utils.validation.constants.ReferenceValidationPolicy; import org.hl7.fhir.utilities.i18n.I18nConstants; import org.hl7.fhir.utilities.xhtml.XhtmlNode; +import org.intellij.lang.annotations.Language; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; @@ -59,16 +93,19 @@ import org.springframework.test.util.AopTestUtils; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.List; import java.util.stream.Collectors; import static ca.uhn.fhir.rest.api.Constants.JAVA_VALIDATOR_DETAILS_SYSTEM; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.fail; import static org.awaitility.Awaitility.await; import static org.hl7.fhir.common.hapi.validation.support.CommonCodeSystemsTerminologyService.CURRENCIES_CODESYSTEM_URL; import static org.hl7.fhir.common.hapi.validation.support.ValidationConstants.LOINC_LOW; import static org.junit.jupiter.api.Assertions.assertEquals; - +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -1452,6 +1489,181 @@ public class FhirResourceDaoR4ValidateTest extends BaseJpaR4Test { throw new IllegalStateException(); // shouldn't get here } + @Test + public void validateResource_withUnknownMetaProfileurl_validatesButLogsWarning() { + // setup + IParser parser = myFhirContext.newJsonParser(); + + myLogbackTestExtension.setUp(Level.WARN); + + String obsStr =""" + { + "resourceType": "Observation", + "meta": { + "profile": [ + "http://example.com/StructureDefinition|a|b|c" + ] + } + } + """; + Observation observation = parser.parseResource(Observation.class, obsStr); + + // test + ValidationModeEnum mode = ValidationModeEnum.CREATE; + MethodOutcome outcome = myObservationDao.validate(observation, null, obsStr, EncodingEnum.JSON, mode, null, mySrd); + + // validator + assertNotNull(outcome); + assertTrue(outcome.getOperationOutcome() instanceof OperationOutcome); + List issues = ((OperationOutcome) outcome.getOperationOutcome()).getIssue(); + assertFalse(issues.isEmpty()); + List errors = issues.stream() + .filter(i -> i.getSeverity() == OperationOutcome.IssueSeverity.ERROR) + .toList(); + // we have errors + assertFalse(errors.isEmpty()); + + List events = myLogbackTestExtension.filterLoggingEventsWithPredicate(e -> { + return e.getLevel() == Level.WARN; + }); + // and we have warning logs + assertFalse(events.isEmpty()); + assertTrue(events.stream().anyMatch(e -> e.getFormattedMessage().contains("Unrecognized profile uri"))); + } + + @Test + public void validateResource_withMetaProfileWithVersion_validatesAsExpected() { + // setup + IParser parser = myFhirContext.newJsonParser(); + + // create our structure definition + @Language("JSON") + String strDefStr = + """ + { + "resourceType": "StructureDefinition", + "id": "example-profile", + "url": "http://example.com/StructureDefinition", + "version": "1.0.0", + "name": "observation-example", + "title": "Example Profile", + "status": "active", + "experimental": false, + "date": "2016-03-25", + "description": "Example Profile", + "fhirVersion": "4.0.1", + "kind": "resource", + "abstract": false, + "type": "Observation", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Observation", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Observation", + "path": "Observation", + "short": "Example Profile", + "alias": [ + "Example" + ], + "min": 0, + "max": "*" + }, + { + "id": "Observation.code", + "path": "Observation.code", + "short": "Coded Responses from C-CDA Vital Sign Results", + "definition": "Coded Responses from C-CDA Vital Sign Results.", + "requirements": "5. SHALL contain exactly one [1..1] code, where the @code SHOULD be selected from ValueSet Example", + "min": 1, + "max": "1", + "type": [ + { + "code": "CodeableConcept" + } + ], + "mustSupport": true, + "binding": { + "strength": "required", + "description": "This identifies the vital sign result type.", + "valueSet": "http://example.com/valueset" + } + } + ] + } + } + """; + StructureDefinition sd = parser.parseResource(StructureDefinition.class, strDefStr); + myStructureDefinitionDao.create(sd, mySrd); + + @Language("JSON") + String obsStr =""" + { + "resourceType": "Observation", + "meta": { + "profile": [ + "http://example.com/StructureDefinition|1.0.0" + ] + }, + "identifier": [ + { + "use": "official", + "system": "http://www.bmc.nl/zorgportal/identifiers/observations", + "value": "6323" + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://example.com/codesystem", + "code": "some-code", + "display": "Some Code" + } + ] + }, + "subject": { + "reference": "Patient/1452" + }, + "effectiveDateTime": "2013-05-02T09:30:10+01:00", + "issued": "2013-04-03T15:30:10+01:00", + "valueQuantity": { + "value": 6.3, + "unit": "mmol/l", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", + "code": "H", + "display": "High" + } + ] + } + ] + } + """; + Observation observation = parser.parseResource(Observation.class, obsStr); + + // test + ValidationModeEnum mode = ValidationModeEnum.CREATE; + MethodOutcome outcome = myObservationDao.validate(observation, null, obsStr, EncodingEnum.JSON, mode, null, mySrd); + + // verify + assertNotNull(outcome); + assertTrue(outcome.getOperationOutcome() instanceof OperationOutcome); + List issues = ((OperationOutcome) outcome.getOperationOutcome()).getIssue(); + assertFalse(issues.isEmpty()); + List errors = issues.stream() + .filter(i -> i.getSeverity() == OperationOutcome.IssueSeverity.ERROR) + .toList(); + // no errors - just warnings + assertTrue(errors.isEmpty(), errors.stream().map(OperationOutcome.OperationOutcomeIssueComponent::getDiagnostics).collect(Collectors.joining(","))); + } + @Test public void testValidateResourceContainingProfileDeclarationInvalid() { String methodName = "testValidateResourceContainingProfileDeclarationInvalid"; diff --git a/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/test/BaseJpaR4Test.java b/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/test/BaseJpaR4Test.java index a584ebc575d..3d2cdc6511a 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/test/BaseJpaR4Test.java +++ b/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/test/BaseJpaR4Test.java @@ -116,6 +116,7 @@ import ca.uhn.fhir.test.utilities.ITestDataBuilder; import ca.uhn.fhir.util.UrlUtil; import ca.uhn.fhir.validation.FhirValidator; import ca.uhn.fhir.validation.ValidationResult; +import ca.uhn.test.util.LogbackTestExtension; import jakarta.persistence.EntityManager; import org.hl7.fhir.common.hapi.validation.support.CachingValidationSupport; import org.hl7.fhir.common.hapi.validation.validator.FhirInstanceValidator; @@ -566,6 +567,9 @@ public abstract class BaseJpaR4Test extends BaseJpaTest implements ITestDataBuil @RegisterExtension private final PreventDanglingInterceptorsExtension myPreventDanglingInterceptorsExtension = new PreventDanglingInterceptorsExtension(()-> myInterceptorRegistry); + @RegisterExtension + public LogbackTestExtension myLogbackTestExtension = new LogbackTestExtension(); + @AfterEach() @Order(0) public void afterCleanupDao() { diff --git a/hapi-fhir-validation/src/main/java/org/hl7/fhir/common/hapi/validation/validator/VersionSpecificWorkerContextWrapper.java b/hapi-fhir-validation/src/main/java/org/hl7/fhir/common/hapi/validation/validator/VersionSpecificWorkerContextWrapper.java index 40e0d8bb706..46e2a142612 100644 --- a/hapi-fhir-validation/src/main/java/org/hl7/fhir/common/hapi/validation/validator/VersionSpecificWorkerContextWrapper.java +++ b/hapi-fhir-validation/src/main/java/org/hl7/fhir/common/hapi/validation/validator/VersionSpecificWorkerContextWrapper.java @@ -51,7 +51,11 @@ import org.slf4j.LoggerFactory; import java.io.FileNotFoundException; import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank; @@ -486,12 +490,22 @@ public class VersionSpecificWorkerContextWrapper extends I18nBase implements IWo } @Override - public T fetchResource(Class class_, String uri) { - - if (isBlank(uri)) { + public T fetchResource(Class class_, String theUri) { + if (isBlank(theUri)) { return null; } + String uri = theUri; + // handle profile version, if present + if (theUri.contains("|")) { + String[] parts = theUri.split("\\|"); + if (parts.length == 2) { + uri = parts[0]; + } else { + ourLog.warn("Unrecognized profile uri: {}", theUri); + } + } + ResourceKey key = new ResourceKey(class_.getSimpleName(), uri); @SuppressWarnings("unchecked") T retVal = (T) myFetchResourceCache.get(key);