diff --git a/.gitignore b/.gitignore index 7468cdc..540363e 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,4 @@ Temporary Items # Helm Chart dependencies **/charts/*.tgz +.claude diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackConfig.java new file mode 100644 index 0000000..2a5d3ab --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackConfig.java @@ -0,0 +1,29 @@ +package ca.uhn.fhir.jpa.starter.validation; + +import ca.uhn.fhir.context.FhirContext; +import org.hl7.fhir.common.hapi.validation.support.ValidationSupportChain; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration that enables versioned URL fallback behavior for FHIR validation. + * + * This wraps the validation support chain to add fallback logic for versioned canonical URLs. + * When a versioned URL like "http://hl7.org/fhir/StructureDefinition/Organization|4.0.1" + * cannot be found, it will automatically fall back to Non-versioned URL (without the |version suffix) + * + * This is useful when Implementation Guides reference versioned base FHIR resources + * that aren't loaded with exact version matching. + */ +@Configuration +public class VersionedUrlFallbackConfig { + + private static final Logger ourLog = LoggerFactory.getLogger(VersionedUrlFallbackConfig.class); + + public VersionedUrlFallbackConfig(FhirContext theFhirContext, ValidationSupportChain theValidationSupportChain) { + ourLog.info("Adding VersionedUrlFallbackValidationSupport to validation chain"); + theValidationSupportChain.addValidationSupport( + 0, new VersionedUrlFallbackValidationSupport(theFhirContext, theValidationSupportChain)); + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupport.java b/src/main/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupport.java new file mode 100644 index 0000000..09bc5a8 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupport.java @@ -0,0 +1,122 @@ +package ca.uhn.fhir.jpa.starter.validation; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.support.IValidationSupport; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Set; +import java.util.function.Function; + +/** + * A validation support that provides fallback behavior for versioned canonical URLs. + * + * When a versioned URL like "http://hl7.org/fhir/StructureDefinition/Organization|4.0.1" + * is requested, this support first tries the exact versioned URL, then falls back to + * the non-versioned URL if not found. + * + * For non-versioned URLs or URLs not matching the configured prefixes, this support + * returns null to let other supports in the chain handle the request. + * + * This addresses issues where profiles reference versioned base FHIR resources that + * aren't available with exact version matching in the validation context. + */ +// TODO: this should be fixed in core +public class VersionedUrlFallbackValidationSupport implements IValidationSupport { + + private static final Logger ourLog = LoggerFactory.getLogger(VersionedUrlFallbackValidationSupport.class); + + private final FhirContext myFhirContext; + private final IValidationSupport myChain; + private final Set myUrlPrefixes; + + /** + * Creates a fallback validation support that only applies to URLs starting with the default prefix + * (http://hl7.org/fhir/StructureDefinition/). + */ + public VersionedUrlFallbackValidationSupport(FhirContext theFhirContext, IValidationSupport theChain) { + this(theFhirContext, theChain, Set.of(URL_PREFIX_STRUCTURE_DEFINITION)); + } + + /** + * Creates a fallback validation support that only applies to URLs starting with the specified prefixes. + * + * @param theFhirContext the FHIR context + * @param theChain the validation support chain to delegate fallback lookups to + * @param theUrlPrefixes the URL prefixes to apply fallback logic to (e.g., "http://hl7.org/fhir/StructureDefinition/"). + * Pass an empty set to apply to all URLs. + */ + public VersionedUrlFallbackValidationSupport( + FhirContext theFhirContext, IValidationSupport theChain, Set theUrlPrefixes) { + myFhirContext = theFhirContext; + myChain = theChain; + myUrlPrefixes = theUrlPrefixes; + } + + @Override + public FhirContext getFhirContext() { + return myFhirContext; + } + + @Override + public T fetchResource(Class theClass, String theUri) { + return doFetchWithFallback(theUri, uri -> myChain.fetchResource(theClass, uri)); + } + + @Override + public IBaseResource fetchStructureDefinition(String theUrl) { + return doFetchWithFallback(theUrl, myChain::fetchStructureDefinition); + } + + private T doFetchWithFallback(String theUrl, Function theFetcher) { + // Check if this is a versioned URL (contains |) + int pipeIndex = theUrl.indexOf('|'); + if (pipeIndex <= 0) { + // Not a versioned URL, let other supports handle it + return null; + } + + String baseUrl = theUrl.substring(0, pipeIndex); + + // Check if this URL matches our configured prefixes + if (!matchesPrefix(baseUrl)) { + return null; + } + + // Try exact versioned URL first + T result = theFetcher.apply(theUrl); + if (result != null) { + return result; + } + + // Try non-versioned URL fallback + result = theFetcher.apply(baseUrl); + if (result != null) { + ourLog.warn( + "Requested versioned canonical '{}' not found, falling back to non-versioned '{}'", + theUrl, + baseUrl); + return result; + } + + return null; + } + + private boolean matchesPrefix(String theUrl) { + if (myUrlPrefixes.isEmpty()) { + return true; + } + for (String prefix : myUrlPrefixes) { + if (theUrl.startsWith(prefix)) { + return true; + } + } + return false; + } + + @Override + public String getName() { + return "VersionedUrlFallbackValidationSupport"; + } +} diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupportTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupportTest.java new file mode 100644 index 0000000..c749388 --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupportTest.java @@ -0,0 +1,447 @@ +package ca.uhn.fhir.jpa.starter.validation; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.support.DefaultProfileValidationSupport; +import ca.uhn.fhir.context.support.IValidationSupport; +import ca.uhn.fhir.validation.FhirValidator; +import ca.uhn.fhir.validation.ValidationResult; +import org.hl7.fhir.common.hapi.validation.support.CommonCodeSystemsTerminologyService; +import org.hl7.fhir.common.hapi.validation.support.InMemoryTerminologyServerValidationSupport; +import org.hl7.fhir.common.hapi.validation.support.ValidationSupportChain; +import org.hl7.fhir.common.hapi.validation.validator.FhirInstanceValidator; +import org.hl7.fhir.r4.model.Observation; +import org.hl7.fhir.r4.model.StructureDefinition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Set; + +import static ca.uhn.fhir.context.support.IValidationSupport.URL_PREFIX_STRUCTURE_DEFINITION; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class VersionedUrlFallbackValidationSupportTest { + + private static final String BASE_FHIR_SD_PREFIX = "http://hl7.org/fhir/StructureDefinition/"; + private static final String ORGANIZATION_URL = BASE_FHIR_SD_PREFIX + "Organization"; + private static final String ORGANIZATION_URL_VERSIONED = ORGANIZATION_URL + "|4.0.1"; + + private static final String CUSTOM_SD_URL = "http://example.com/StructureDefinition/MyProfile"; + private static final String CUSTOM_SD_URL_VERSIONED = CUSTOM_SD_URL + "|1.0.0"; + + private FhirContext myFhirContext; + + @Mock + private IValidationSupport myChain; + + private VersionedUrlFallbackValidationSupport mySvc; + + @BeforeEach + void setUp() { + myFhirContext = FhirContext.forR4Cached(); + mySvc = new VersionedUrlFallbackValidationSupport(myFhirContext, myChain); + } + + @Test + void testExactVersionedUrl_ReturnedWithoutFallback() { + // Setup: exact versioned URL is available + StructureDefinition sd = new StructureDefinition(); + sd.setUrl(ORGANIZATION_URL); + sd.setVersion("4.0.1"); + + when(myChain.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED)).thenReturn(sd); + + // Execute + var result = mySvc.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED); + + // Verify: returns exact match, no fallback attempted + assertNotNull(result); + assertSame(sd, result); + verify(myChain).fetchStructureDefinition(ORGANIZATION_URL_VERSIONED); + verify(myChain, never()).fetchStructureDefinition(ORGANIZATION_URL); + } + + @Test + void testFallbackToNonVersionedUrl() { + // Setup: exact versioned URL not found, non-versioned returns a resource + StructureDefinition sd = new StructureDefinition(); + sd.setUrl(ORGANIZATION_URL); + + when(myChain.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED)).thenReturn(null); + when(myChain.fetchStructureDefinition(ORGANIZATION_URL)).thenReturn(sd); + + // Execute + var result = mySvc.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED); + + // Verify: fallback to non-versioned succeeds + assertNotNull(result); + assertSame(sd, result); + verify(myChain).fetchStructureDefinition(ORGANIZATION_URL_VERSIONED); + verify(myChain).fetchStructureDefinition(ORGANIZATION_URL); + } + + @Test + void testNoFallback_ForNonVersionedUrl() { + // Execute: non-versioned URL should pass through without any chain calls + var result = mySvc.fetchStructureDefinition(ORGANIZATION_URL); + + // Verify: returns null immediately, lets other chain supports handle it + assertNull(result); + verifyNoInteractions(myChain); + } + + @Test + void testNoFallback_ForCustomUrlNotMatchingDefaultPrefix() { + // Execute: custom URL doesn't match the default prefix filter + var result = mySvc.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED); + + // Verify: returns null, doesn't attempt fallback (not in prefix list) + assertNull(result); + verifyNoInteractions(myChain); + } + + @Test + void testFallback_ForCustomUrl_WhenPrefixConfigured() { + // Setup: configure to also handle custom URLs + mySvc = new VersionedUrlFallbackValidationSupport(myFhirContext, myChain, + Set.of(BASE_FHIR_SD_PREFIX, "http://example.com/StructureDefinition/")); + + StructureDefinition sd = new StructureDefinition(); + sd.setUrl(CUSTOM_SD_URL); + + when(myChain.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED)).thenReturn(null); + when(myChain.fetchStructureDefinition(CUSTOM_SD_URL)).thenReturn(sd); + + // Execute + var result = mySvc.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED); + + // Verify + assertNotNull(result); + assertSame(sd, result); + } + + @Test + void testFallback_ForAllUrls_WhenEmptyPrefixSet() { + // Setup: empty prefix set means apply to all URLs + mySvc = new VersionedUrlFallbackValidationSupport(myFhirContext, myChain, Set.of()); + + StructureDefinition sd = new StructureDefinition(); + sd.setUrl(CUSTOM_SD_URL); + + when(myChain.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED)).thenReturn(null); + when(myChain.fetchStructureDefinition(CUSTOM_SD_URL)).thenReturn(sd); + + // Execute + var result = mySvc.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED); + + // Verify + assertNotNull(result); + assertSame(sd, result); + } + + @Test + void testFetchResource_FallbackToNonVersioned() { + // Setup + StructureDefinition sd = new StructureDefinition(); + sd.setUrl(ORGANIZATION_URL); + + when(myChain.fetchResource(StructureDefinition.class, ORGANIZATION_URL_VERSIONED)).thenReturn(null); + when(myChain.fetchResource(StructureDefinition.class, ORGANIZATION_URL)).thenReturn(sd); + + // Execute + var result = mySvc.fetchResource(StructureDefinition.class, ORGANIZATION_URL_VERSIONED); + + // Verify + assertNotNull(result); + assertSame(sd, result); + } + + @Test + void testFetchResource_NoFallbackForNonMatchingPrefix() { + // Execute + var result = mySvc.fetchResource(StructureDefinition.class, CUSTOM_SD_URL_VERSIONED); + + // Verify + assertNull(result); + verifyNoInteractions(myChain); + } + + @Test + void testFetchResource_NoFallbackForNonVersionedUrl() { + // Execute + var result = mySvc.fetchResource(StructureDefinition.class, ORGANIZATION_URL); + + // Verify + assertNull(result); + verifyNoInteractions(myChain); + } + + @Test + void testReturnsNull_WhenNoFallbackSucceeds() { + // Setup: nothing found in any lookup + when(myChain.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED)).thenReturn(null); + when(myChain.fetchStructureDefinition(ORGANIZATION_URL)).thenReturn(null); + + // Execute + var result = mySvc.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED); + + // Verify + assertNull(result); + verify(myChain).fetchStructureDefinition(ORGANIZATION_URL_VERSIONED); + verify(myChain).fetchStructureDefinition(ORGANIZATION_URL); + } + + @Test + void testGetName() { + assertEquals("VersionedUrlFallbackValidationSupport", mySvc.getName()); + } + + @Test + void testGetFhirContext() { + assertSame(myFhirContext, mySvc.getFhirContext()); + } + + @Test + void testDefaultUrlPrefix() { + assertEquals("http://hl7.org/fhir/StructureDefinition/", + URL_PREFIX_STRUCTURE_DEFINITION); + } + + /** + * Integration tests using real DefaultProfileValidationSupport instead of mocks. + * This tests the actual fallback behavior with FHIR's built-in profiles. + */ + @Nested + class WithRealValidationChain { + + private FhirContext myFhirContext; + private ValidationSupportChain myValidationChain; + private VersionedUrlFallbackValidationSupport mySvc; + + @BeforeEach + void setUp() { + myFhirContext = FhirContext.forR4Cached(); + + // Create a validation chain with the real DefaultProfileValidationSupport + // which contains all built-in FHIR R4 StructureDefinitions + myValidationChain = new ValidationSupportChain(new DefaultProfileValidationSupport(myFhirContext)); + + // Wrap the chain with our fallback support, similar to production setup + mySvc = new VersionedUrlFallbackValidationSupport(myFhirContext, myValidationChain); + } + + @Test + void testFallbackToNonVersionedUrl_WithRealDefaultProfile() { + // The DefaultProfileValidationSupport has Organization without version suffix. + // When we request versioned URL, it should fall back and find it. + String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Organization|4.0.1"; + + var result = mySvc.fetchStructureDefinition(versionedUrl); + + assertNotNull(result, "Should find Organization via fallback to non-versioned URL"); + assertInstanceOf(StructureDefinition.class, result); + StructureDefinition sd = (StructureDefinition) result; + assertEquals("http://hl7.org/fhir/StructureDefinition/Organization", sd.getUrl()); + assertEquals("Organization", sd.getName()); + } + + @Test + void testFallbackForPatient_WithRealDefaultProfile() { + String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Patient|4.0.1"; + + var result = mySvc.fetchStructureDefinition(versionedUrl); + + assertNotNull(result, "Should find Patient via fallback"); + assertInstanceOf(StructureDefinition.class, result); + StructureDefinition sd = (StructureDefinition) result; + assertEquals("http://hl7.org/fhir/StructureDefinition/Patient", sd.getUrl()); + } + + @Test + void testFetchResource_WithRealDefaultProfile() { + String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Observation|4.0.1"; + + var result = mySvc.fetchResource(StructureDefinition.class, versionedUrl); + + assertNotNull(result, "Should find Observation via fetchResource fallback"); + assertEquals("http://hl7.org/fhir/StructureDefinition/Observation", result.getUrl()); + } + + @Test + void testNonExistentResource_ReturnsNull() { + String versionedUrl = "http://hl7.org/fhir/StructureDefinition/NonExistentResource|1.0.0"; + + var result = mySvc.fetchStructureDefinition(versionedUrl); + + assertNull(result, "Should return null for non-existent resource"); + } + + @Test + void testNonVersionedUrl_PassesThrough() { + // Non-versioned URLs should return null from the fallback support + // (they're handled by DefaultProfileValidationSupport directly in a real chain) + String nonVersionedUrl = "http://hl7.org/fhir/StructureDefinition/Patient"; + + var result = mySvc.fetchStructureDefinition(nonVersionedUrl); + + // The fallback support returns null for non-versioned URLs + // In a real setup, the chain would handle this + assertNull(result); + } + + @Test + void testDataTypeProfiles_WithRealDefaultProfile() { + // Test that data type StructureDefinitions also work + String versionedUrl = "http://hl7.org/fhir/StructureDefinition/HumanName|4.0.1"; + + var result = mySvc.fetchStructureDefinition(versionedUrl); + + assertNotNull(result, "Should find HumanName data type via fallback"); + assertInstanceOf(StructureDefinition.class, result); + assertEquals("http://hl7.org/fhir/StructureDefinition/HumanName", + ((StructureDefinition) result).getUrl()); + } + } + + /** + * Integration tests where VersionedUrlFallbackValidationSupport is part of the + * ValidationSupportChain (as in production) rather than wrapping it. + */ + @Nested + class WithFallbackInChain { + + private FhirContext myFhirContext; + private ValidationSupportChain myValidationChain; + + @BeforeEach + void setUp() { + myFhirContext = FhirContext.forR4Cached(); + DefaultProfileValidationSupport defaultSupport = new DefaultProfileValidationSupport(myFhirContext); + + // Create a chain where VersionedUrlFallbackValidationSupport is a member + // This mimics production setup where the fallback is part of the chain + myValidationChain = new ValidationSupportChain(defaultSupport); + VersionedUrlFallbackValidationSupport fallbackSupport = + new VersionedUrlFallbackValidationSupport(myFhirContext, myValidationChain); + + // Rebuild chain with fallback support first (higher priority) + myValidationChain = new ValidationSupportChain(fallbackSupport, defaultSupport); + } + + @Test + void testChainResolvesVersionedUrl() { + String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Patient|4.0.1"; + + var result = myValidationChain.fetchStructureDefinition(versionedUrl); + + assertNotNull(result, "Chain should resolve versioned URL via fallback"); + assertInstanceOf(StructureDefinition.class, result); + assertEquals("http://hl7.org/fhir/StructureDefinition/Patient", + ((StructureDefinition) result).getUrl()); + } + + @Test + void testChainResolvesNonVersionedUrl() { + // Non-versioned URLs should still work (handled by DefaultProfileValidationSupport) + String nonVersionedUrl = "http://hl7.org/fhir/StructureDefinition/Patient"; + + var result = myValidationChain.fetchStructureDefinition(nonVersionedUrl); + + assertNotNull(result, "Chain should resolve non-versioned URL directly"); + assertInstanceOf(StructureDefinition.class, result); + } + + @Test + void testChainFetchResource() { + String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Encounter|4.0.1"; + + var result = myValidationChain.fetchResource(StructureDefinition.class, versionedUrl); + + assertNotNull(result, "fetchResource should work through chain with fallback"); + assertEquals("http://hl7.org/fhir/StructureDefinition/Encounter", result.getUrl()); + } + + @Test + void testMultipleResourceTypes() { + // Verify fallback works for various resource types + String[] versionedUrls = { + "http://hl7.org/fhir/StructureDefinition/Condition|4.0.1", + "http://hl7.org/fhir/StructureDefinition/Medication|4.0.1", + "http://hl7.org/fhir/StructureDefinition/DiagnosticReport|4.0.1" + }; + + for (String versionedUrl : versionedUrls) { + var result = myValidationChain.fetchStructureDefinition(versionedUrl); + assertNotNull(result, "Should resolve " + versionedUrl); + } + } + } + + /** + * Full validation integration tests using FhirInstanceValidator to prove + * the fallback support works in actual resource validation scenarios. + */ + @Nested + class WithFhirInstanceValidator { + + private FhirContext myFhirContext; + private FhirValidator myValidator; + + @BeforeEach + void setUp() { + myFhirContext = FhirContext.forR4Cached(); + DefaultProfileValidationSupport defaultSupport = new DefaultProfileValidationSupport(myFhirContext); + InMemoryTerminologyServerValidationSupport terminologySupport = + new InMemoryTerminologyServerValidationSupport(myFhirContext); + CommonCodeSystemsTerminologyService commonCodeSystems = + new CommonCodeSystemsTerminologyService(myFhirContext); + + // Build production-like validation chain with fallback support + ValidationSupportChain baseChain = new ValidationSupportChain( + defaultSupport, + terminologySupport, + commonCodeSystems + ); + + VersionedUrlFallbackValidationSupport fallbackSupport = + new VersionedUrlFallbackValidationSupport(myFhirContext, baseChain); + + // ValidationSupportChain now handles caching internally (since HAPI FHIR 8.0.0) + ValidationSupportChain fullChain = new ValidationSupportChain( + fallbackSupport, + defaultSupport, + terminologySupport, + commonCodeSystems + ); + + FhirInstanceValidator instanceValidator = new FhirInstanceValidator(fullChain); + myValidator = myFhirContext.newValidator(); + myValidator.registerValidatorModule(instanceValidator); + } + + @Test + void testValidateSimpleObservation() { + Observation observation = new Observation(); + observation.setStatus(Observation.ObservationStatus.FINAL); + observation.getCode().addCoding() + .setSystem("http://loinc.org") + .setCode("12345-6") + .setDisplay("Test"); + + ValidationResult result = myValidator.validateWithResult(observation); + + // The validation should complete without errors related to unresolved versioned URLs + assertNotNull(result); + // We don't require the resource to be fully valid (may have other issues) + // but it should not fail due to missing versioned profile resolution + assertTrue(result.getMessages().stream() + .noneMatch(m -> m.getMessage().contains("Unable to locate profile")), + "Should not have profile resolution errors"); + } + } +}