Add fallback for versioned base FHIR StructureDefinition lookups (#911)

* Add fallback for versioned base FHIR StructureDefinition lookups

Problem:
Implementation Guides (e.g., ch-core) may reference base FHIR resources
with explicit versions like "http://hl7.org/fhir/StructureDefinition/Organization|4.0.1".
These lookups fail because base FHIR StructureDefinitions are intentionally
cached without version (see hapi-fhir PR #7236).

This causes validation errors like:
- "Unable to resolve the profile reference '...|4.0.1'"
- "Invalid Resource target type. Found Organization, but expected one of ([])"

Solution:
Add VersionedUrlFallbackValidationSupport that intercepts versioned lookups
for base FHIR StructureDefinitions (http://hl7.org/fhir/StructureDefinition/*)
and falls back to:
1. Major.minor version (e.g., 4.0.1 -> 4.0)
2. Non-versioned URL

The fallback logs a warning when triggered, allowing visibility into
which IGs reference versioned base resources.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove duplicates

* formatting

* removing unneeded thread local

* corrected according to review

* cleanup

* removed not needed const

* tests include some chain now

* Update src/test/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupportTest.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* updated doc

* adding tests

* Add TODO comment for core fix in validation support

Added a comment indicating a TODO for future improvement.

* Update TODO comment for core fix in validation support

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jens Kristian Villadsen
2026-02-05 15:28:02 +01:00
committed by GitHub
parent fc395b613a
commit 63256fe1d2
4 changed files with 599 additions and 0 deletions

1
.gitignore vendored
View File

@@ -167,3 +167,4 @@ Temporary Items
# Helm Chart dependencies
**/charts/*.tgz
.claude

View File

@@ -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));
}
}

View File

@@ -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<String> 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<String> theUrlPrefixes) {
myFhirContext = theFhirContext;
myChain = theChain;
myUrlPrefixes = theUrlPrefixes;
}
@Override
public FhirContext getFhirContext() {
return myFhirContext;
}
@Override
public <T extends IBaseResource> T fetchResource(Class<T> theClass, String theUri) {
return doFetchWithFallback(theUri, uri -> myChain.fetchResource(theClass, uri));
}
@Override
public IBaseResource fetchStructureDefinition(String theUrl) {
return doFetchWithFallback(theUrl, myChain::fetchStructureDefinition);
}
private <T extends IBaseResource> T doFetchWithFallback(String theUrl, Function<String, T> 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";
}
}

View File

@@ -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");
}
}
}