From 63256fe1d2864273f2ae2d4c095050cd4cbc517d Mon Sep 17 00:00:00 2001 From: Jens Kristian Villadsen Date: Thu, 5 Feb 2026 15:28:02 +0100 Subject: [PATCH 01/11] 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 * 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 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 1 + .../VersionedUrlFallbackConfig.java | 29 ++ ...VersionedUrlFallbackValidationSupport.java | 122 +++++ ...ionedUrlFallbackValidationSupportTest.java | 447 ++++++++++++++++++ 4 files changed, 599 insertions(+) create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackConfig.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupport.java create mode 100644 src/test/java/ca/uhn/fhir/jpa/starter/validation/VersionedUrlFallbackValidationSupportTest.java 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"); + } + } +} From c5fd867efcae29b04284907413bc158b183815f1 Mon Sep 17 00:00:00 2001 From: Steve Swinsburg Date: Mon, 9 Feb 2026 01:35:14 +1100 Subject: [PATCH 02/11] Fix the build warning by replacing the deprecated prerequisites property and replacing with the enforcer plugin. (#913) Co-authored-by: Steve Swinsburg --- pom.xml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 9e35f2a..a22875e 100644 --- a/pom.xml +++ b/pom.xml @@ -41,10 +41,6 @@ ${project.parent.version}-${hapi.fhir.jpa.server.starter.revision} war - - 3.8.3 - - HAPI FHIR JPA Server - Starter Project @@ -549,6 +545,28 @@ + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.2 + + + enforce-maven + + enforce + + + + + 3.8.3 + + + + + + + org.apache.maven.plugins From 78a068ab45108205301f2e697d3da7e2508e1105 Mon Sep 17 00:00:00 2001 From: Jens Kristian Villadsen Date: Sun, 8 Feb 2026 17:18:28 +0100 Subject: [PATCH 03/11] Feature/elastic back in green (#893) * Getting automated tests back into green * using native API's * formatting * refactoring * getting 8.X ES working * Removed sleep * Update src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Reintroduced sleep as indicies are a bit slow ? * Using the production bean instead of a test one * updating default parameters for es * Making separate profile for ES * works from here * remove all not needed code anymore * removing parent defined version * fixed tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pom.xml | 15 +-- .../jpa/starter/common/StarterJpaConfig.java | 2 + .../elastic/ElasticsearchBootSvcImpl.java | 12 +-- src/main/resources/application-elastic.yaml | 50 +++++++++ src/main/resources/application.yaml | 37 +++++-- .../jpa/starter/ElasticsearchLastNR4IT.java | 101 +++++++++--------- 6 files changed, 142 insertions(+), 75 deletions(-) create mode 100644 src/main/resources/application-elastic.yaml diff --git a/pom.xml b/pom.xml index a22875e..521e3b7 100644 --- a/pom.xml +++ b/pom.xml @@ -307,25 +307,18 @@ org.testcontainers testcontainers - 2.0.3 test org.testcontainers - elasticsearch - 1.21.4 + testcontainers-elasticsearch + 2.0.2 test org.testcontainers - postgresql - 1.21.4 - test - - - org.testcontainers - junit-jupiter - 1.21.4 + testcontainers-junit-jupiter + 2.0.2 test diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java index 1c1d2c7..1e3bc6a 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java @@ -50,6 +50,7 @@ import ca.uhn.fhir.jpa.starter.AppProperties; import ca.uhn.fhir.jpa.starter.annotations.OnCorsPresent; import ca.uhn.fhir.jpa.starter.annotations.OnImplementationGuidesPresent; import ca.uhn.fhir.jpa.starter.common.validation.IRepositoryValidationInterceptorFactory; +import ca.uhn.fhir.jpa.starter.elastic.ElasticsearchBootSvcImpl; import ca.uhn.fhir.jpa.starter.ig.ExtendedPackageInstallationSpec; import ca.uhn.fhir.jpa.starter.ig.IImplementationGuideOperationProvider; import ca.uhn.fhir.jpa.subscription.util.SubscriptionDebugLogInterceptor; @@ -149,6 +150,7 @@ public class StarterJpaConfig { @Primary @Bean public LocalContainerEntityManagerFactoryBean entityManagerFactory( + Optional elasticsearchSvc, JpaProperties theJpaProperties, DataSource myDataSource, ConfigurableListableBeanFactory myConfigurableListableBeanFactory, diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java index a6299f7..fdc197f 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java @@ -50,7 +50,7 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc { private static final String OBSERVATION_RESOURCE_NAME = "Observation"; - private final ElasticsearchClient myRestHighLevelClient; + private final ElasticsearchClient myElasticsearchClient; private final FhirContext myContext; @@ -61,7 +61,7 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc { public ElasticsearchBootSvcImpl(ElasticsearchClient client, FhirContext fhirContext, AppProperties appProperties) { myContext = fhirContext; - myRestHighLevelClient = client; + myElasticsearchClient = client; // Determine index prefix from configuration if (appProperties.getElasticsearch() != null) { @@ -144,7 +144,7 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc { } private boolean createIndex(String theIndexName, String theMapping) throws IOException { - return myRestHighLevelClient + return myElasticsearchClient .indices() .create(cir -> cir.index(theIndexName).withJson(new StringReader(theMapping))) .acknowledged(); @@ -152,7 +152,7 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc { private boolean indexExists(String theIndexName) throws IOException { ExistsRequest request = new ExistsRequest.Builder().index(theIndexName).build(); - return myRestHighLevelClient.indices().exists(request).value(); + return myElasticsearchClient.indices().exists(request).value(); } @Override @@ -165,7 +165,7 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc { SearchRequest searchRequest = buildObservationResourceSearchRequest(thePids); try { SearchResponse observationDocumentResponse = - myRestHighLevelClient.search(searchRequest, ObservationJson.class); + myElasticsearchClient.search(searchRequest, ObservationJson.class); List> observationDocumentHits = observationDocumentResponse.hits().hits(); IParser parser = TolerantJsonParser.createWithLenientErrorHandling(myContext, null); @@ -202,7 +202,7 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc { @VisibleForTesting public void refreshIndex(String theIndexName) throws IOException { - myRestHighLevelClient.indices().refresh(fn -> fn.index(theIndexName)); + myElasticsearchClient.indices().refresh(fn -> fn.index(theIndexName)); } /** diff --git a/src/main/resources/application-elastic.yaml b/src/main/resources/application-elastic.yaml new file mode 100644 index 0000000..68b07be --- /dev/null +++ b/src/main/resources/application-elastic.yaml @@ -0,0 +1,50 @@ +spring: + elasticsearch: + uris: http://localhost:9200 + username: elastic + password: elastic + + autoconfigure: + # This empty exclude is needed to override the default exclusion of the Elasticsearch configuration. + exclude: + + jpa: + properties: + hibernate: + # --- Hibernate Search (Lucene/Elasticsearch) --- + # Note: the following values should be kept in sync with ca.uhn.fhir.jpa.search.elastic.ElasticsearchHibernatePropertiesBuilder + search: + schema_management: + strategy: CREATE + enabled: true + backend: + layout: + strategy: ca.uhn.fhir.jpa.search.elastic.IndexNamePrefixLayoutStrategy + type: elasticsearch + protocol: http + analysis: + configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticsearchAnalysisConfigurer + scroll_timeout: 60 + schema_management: + settings_file: ca/uhn/fhir/jpa/elastic/index-settings.json + minimal_required_status_wait_timeout: 10000 + minimal_required_status: YELLOW + + dynamic_mapping: true + indexing: + plan: + synchronization: + strategy: async + +# ------------------------------------------------------------------------------------- +# HAPI FHIR — grouped by domain +# ------------------------------------------------------------------------------------- +hapi: + fhir: + # ------------------------------------------------------------------------------- + # D. Search & Indexing + # ------------------------------------------------------------------------------- + # NOTE: Extended Lucene/Elasticsearch indexing is experimental. + # See https://hapifhir.io/hapi-fhir/docs/server_jpa/elastic.html + advanced_lucene_indexing: true + search_index_full_text_enabled: true diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8afc883..07e3961 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,6 +37,10 @@ management: enabled: true spring: +# elasticsearch: +# uris: http://localhost:9200 +# username: elastic +# password: elastic # ------------------------------------------------------------------------------- # Application Name # ------------------------------------------------------------------------------- @@ -130,8 +134,10 @@ spring: use_minimal_puts: false # --- Hibernate Search (Lucene/Elasticsearch) --- - search: - enabled: false + #search: + # schema_management: + # strategy: CREATE + # enabled: true # Lucene backend (default example) # backend: # type: lucene @@ -142,10 +148,25 @@ spring: # root: target/lucenefiles # lucene_version: lucene_current # Elasticsearch backend (alternative) — see also hapi.fhir.elasticsearch section in docs - # backend: - # type: elasticsearch - # analysis: - # configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer +# backend: +# layout: +# strategy: ca.uhn.fhir.jpa.search.elastic.IndexNamePrefixLayoutStrategy +# type: elasticsearch +# protocol: http +# analysis: +# configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticsearchAnalysisConfigurer +# scroll_timeout: 60 +# schema_management: +# settings_file: ca/uhn/fhir/jpa/elastic/index-settings.json +# minimal_required_status_wait_timeout: 10000 +# minimal_required_status: YELLOW +# +# dynamic_mapping: true +# indexing: +# plan: +# synchronization: +# strategy: async + # ------------------------------------------------------------------------------------- # HAPI FHIR — grouped by domain @@ -249,8 +270,8 @@ hapi: # ------------------------------------------------------------------------------- # NOTE: Extended Lucene/Elasticsearch indexing is experimental. # See https://hapifhir.io/hapi-fhir/docs/server_jpa/elastic.html - advanced_lucene_indexing: false - search_index_full_text_enabled: false + # advanced_lucene_indexing: true + # search_index_full_text_enabled: true # language_search_parameter_enabled: true # upliftedRefchains_enabled: true # index_storage_optimized: false diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java index 42c3198..c2ed69a 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java @@ -1,7 +1,5 @@ package ca.uhn.fhir.jpa.starter; -import static org.junit.jupiter.api.Assertions.assertEquals; - import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl; import ca.uhn.fhir.jpa.starter.common.TestContainerHelper; @@ -36,69 +34,72 @@ import org.testcontainers.elasticsearch.ElasticsearchContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import static org.junit.jupiter.api.Assertions.assertEquals; + @Testcontainers @ActiveProfiles("test") @TestPropertySource(locations = "classpath:test-elasticsearch-lastn.yaml") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}) class ElasticsearchLastNR4IT { - private IGenericClient ourClient; + private IGenericClient ourClient; - @Container - private static final ElasticsearchContainer ELASTICSEARCH = TestContainerHelper.newElasticsearchContainer() - // Set index defaults to handle HAPI FHIR's MAX_SUBSCRIPTION_RESULTS (50000) - .withEnv("indices.query.bool.max_clause_count", "50000"); + @Container + private static final ElasticsearchContainer ELASTICSEARCH = TestContainerHelper.newElasticsearchContainer() + // Set index defaults to handle HAPI FHIR's MAX_SUBSCRIPTION_RESULTS (50000) + .withEnv("indices.query.bool.max_clause_count", "50000"); - @DynamicPropertySource - static void registerElasticsearchProperties(DynamicPropertyRegistry registry) { - TestContainerHelper.registerElasticsearchProperties(registry, ELASTICSEARCH); - // Also register spring.elasticsearch.uris for ElasticConfigCondition to enable ElasticsearchBootSvcImpl - registry.add("spring.elasticsearch.uris", () -> TestContainerHelper.getElasticsearchHttpUrl(ELASTICSEARCH)); - } + @DynamicPropertySource + static void registerElasticsearchProperties(DynamicPropertyRegistry registry) { + TestContainerHelper.registerElasticsearchProperties(registry, ELASTICSEARCH); + // Also register spring.elasticsearch.uris for ElasticConfigCondition to enable ElasticsearchBootSvcImpl + registry.add("spring.elasticsearch.uris", () -> TestContainerHelper.getElasticsearchHttpUrl(ELASTICSEARCH)); - @Autowired - private ElasticsearchBootSvcImpl myElasticsearchSvc; + registry.add("spring.jpa.properties.hibernate.search.backend.hosts", ELASTICSEARCH::getHttpHostAddress); + registry.add("spring.jpa.properties.hibernate.search.backend.protocol", () -> "http"); + registry.add("spring.jpa.properties.hibernate.search.backend.username", () -> ""); + registry.add("spring.jpa.properties.hibernate.search.backend.password", () -> ""); + } - @LocalServerPort - private int port; + @Autowired + private ElasticsearchBootSvcImpl myElasticsearchSvc; - @Test - void testLastN() throws IOException, InterruptedException { - Thread.sleep(2000); + @LocalServerPort + private int port; - Patient pt = new Patient(); - pt.addName().setFamily("Lastn").addGiven("Arthur"); - IIdType id = ourClient.create().resource(pt).execute().getId().toUnqualifiedVersionless(); + @Test + void testLastN() throws IOException, InterruptedException { + Patient pt = new Patient(); + pt.addName().setFamily("Lastn").addGiven("Arthur"); + IIdType id = ourClient.create().resource(pt).execute().getId().toUnqualifiedVersionless(); - Observation obs = new Observation(); - obs.getSubject().setReferenceElement(id); - String observationCode = "testobservationcode"; + Observation obs = new Observation(); + obs.getSubject().setReferenceElement(id); + String observationCode = "testobservationcode"; - obs.getCode().addCoding().setCode(observationCode).setSystem("http://testobservationcodesystem"); - obs.setValue(new StringType(observationCode)); - Date effectiveDtm = new GregorianCalendar().getTime(); - obs.setEffective(new DateTimeType(effectiveDtm)); - obs.getCategoryFirstRep().addCoding().setCode("testcategorycode").setSystem("http://testcategorycodesystem"); - IIdType obsId = ourClient.create().resource(obs).execute().getId().toUnqualifiedVersionless(); + obs.getCode().addCoding().setCode(observationCode).setSystem("http://testobservationcodesystem"); + obs.setValue(new StringType(observationCode)); - myElasticsearchSvc.refreshIndex(ElasticsearchSvcImpl.OBSERVATION_INDEX); - Thread.sleep(2000); + Date effectiveDtm = new GregorianCalendar().getTime(); + obs.setEffective(new DateTimeType(effectiveDtm)); + obs.getCategoryFirstRep().addCoding().setCode("testcategorycode").setSystem("http://testcategorycodesystem"); + IIdType obsId = ourClient.create().resource(obs).execute().getId().toUnqualifiedVersionless(); - Parameters output = ourClient.operation().onType(Observation.class).named("lastn") - .withParameter(Parameters.class, "max", new IntegerType(1)) - .andParameter("subject", new StringType("Patient/" + id.getIdPart())) - .execute(); - Bundle b = (Bundle) output.getParameter().get(0).getResource(); - assertEquals(1, b.getTotal()); - assertEquals(obsId, b.getEntry().get(0).getResource().getIdElement().toUnqualifiedVersionless()); - } + myElasticsearchSvc.refreshIndex(ElasticsearchSvcImpl.OBSERVATION_INDEX); - @BeforeEach - void beforeEach() { - FhirContext ctx = FhirContext.forR4(); - ctx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER); - ctx.getRestfulClientFactory().setSocketTimeout((int) Duration.ofMinutes(20).toMillis()); - ourClient = ctx.newRestfulGenericClient("http://localhost:" + port + "/fhir/"); - ourClient.registerInterceptor(new LoggingInterceptor(true)); - } + Thread.sleep(2000); + Parameters output = ourClient.operation().onType(Observation.class).named("lastn").withParameter(Parameters.class, "max", new IntegerType(1)).andParameter("subject", new StringType("Patient/" + id.getIdPart())).execute(); + Bundle b = (Bundle) output.getParameter().get(0).getResource(); + assertEquals(1, b.getTotal()); + assertEquals(obsId, b.getEntry().get(0).getResource().getIdElement().toUnqualifiedVersionless()); + } + + @BeforeEach + void beforeEach() { + FhirContext ctx = FhirContext.forR4(); + ctx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER); + ctx.getRestfulClientFactory().setSocketTimeout((int) Duration.ofMinutes(20).toMillis()); + ourClient = ctx.newRestfulGenericClient("http://localhost:" + port + "/fhir/"); + ourClient.registerInterceptor(new LoggingInterceptor(true)); + } } From f4db064f92114112fb7526b9275ef4c5653b4e1b Mon Sep 17 00:00:00 2001 From: Patrick Werner Date: Mon, 16 Feb 2026 11:55:17 +0100 Subject: [PATCH 04/11] feat: add BinaryStorageInterceptorRegistrar for conditional interceptor registration, register against JPA interceptor service to also be triggered on dao level (e.g. bulk export) --- .../BinaryStorageInterceptorRegistrar.java | 41 +++++++++++++++++++ .../jpa/starter/common/StarterJpaConfig.java | 3 -- 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/common/BinaryStorageInterceptorRegistrar.java diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/BinaryStorageInterceptorRegistrar.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/BinaryStorageInterceptorRegistrar.java new file mode 100644 index 0000000..79b5134 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/BinaryStorageInterceptorRegistrar.java @@ -0,0 +1,41 @@ +package ca.uhn.fhir.jpa.starter.common; + +import ca.uhn.fhir.IHapiBootOrder; +import ca.uhn.fhir.interceptor.api.IInterceptorService; +import ca.uhn.fhir.jpa.binary.interceptor.BinaryStorageInterceptor; +import ca.uhn.fhir.jpa.starter.AppProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +public class BinaryStorageInterceptorRegistrar { + private static final Logger ourLog = LoggerFactory.getLogger(BinaryStorageInterceptorRegistrar.class); + + private final IInterceptorService myInterceptorService; + private final BinaryStorageInterceptor myBinaryStorageInterceptor; + private final AppProperties myAppProperties; + + public BinaryStorageInterceptorRegistrar( + IInterceptorService theInterceptorService, + BinaryStorageInterceptor theBinaryStorageInterceptor, + AppProperties theAppProperties) { + myInterceptorService = theInterceptorService; + myBinaryStorageInterceptor = theBinaryStorageInterceptor; + myAppProperties = theAppProperties; + } + + @EventListener(classes = {ContextRefreshedEvent.class}) + @Order(IHapiBootOrder.REGISTER_INTERCEPTORS) + public void register() { + if (!myAppProperties.getBinary_storage_enabled()) { + ourLog.debug("Binary storage disabled; skipping BinaryStorageInterceptor registration"); + return; + } + ourLog.info("Registering BinaryStorageInterceptor with JPA interceptor service"); + myInterceptorService.registerInterceptor(myBinaryStorageInterceptor); + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java index 1e3bc6a..86e4793 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java @@ -15,7 +15,6 @@ import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.config.ThreadPoolFactoryConfig; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; import ca.uhn.fhir.jpa.api.dao.IFhirSystemDao; -import ca.uhn.fhir.jpa.binary.interceptor.BinaryStorageInterceptor; import ca.uhn.fhir.jpa.binary.provider.BinaryAccessProvider; import ca.uhn.fhir.jpa.config.util.HapiEntityManagerFactoryUtil; import ca.uhn.fhir.jpa.config.util.ResourceCountCacheUtil; @@ -323,7 +322,6 @@ public class StarterJpaConfig { Optional corsInterceptor, IInterceptorBroadcaster interceptorBroadcaster, Optional binaryAccessProvider, - BinaryStorageInterceptor binaryStorageInterceptor, IValidatorModule validatorModule, Optional graphQLProvider, BulkDataExportProvider bulkDataExportProvider, @@ -455,7 +453,6 @@ public class StarterJpaConfig { // Binary Storage if (appProperties.getBinary_storage_enabled() && binaryAccessProvider.isPresent()) { fhirServer.registerProvider(binaryAccessProvider.get()); - fhirServer.registerInterceptor(binaryStorageInterceptor); } // Validation From a1fc68b4f07d5b863fc14af331e5c86f4254b8c9 Mon Sep 17 00:00:00 2001 From: Patrick Werner Date: Mon, 16 Feb 2026 11:58:57 +0100 Subject: [PATCH 05/11] removed deprecated setting of setInlineResourceTextBelowSize --- .../fhir/jpa/starter/common/FhirServerConfigCommon.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java index 88c5874..0c5bb58 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java @@ -227,11 +227,6 @@ public class FhirServerConfigCommon { jpaStorageSettings.setLastNEnabled(true); } - Integer inlineResourceThreshold = resolveInlineResourceThreshold(appProperties); - if (inlineResourceThreshold != null && inlineResourceThreshold != 0) { - jpaStorageSettings.setInlineResourceTextBelowSize(inlineResourceThreshold); - } - jpaStorageSettings.setStoreResourceInHSearchIndex(appProperties.getStore_resource_in_lucene_index_enabled()); jpaStorageSettings.setNormalizedQuantitySearchLevel(appProperties.getNormalized_quantity_search_level()); jpaStorageSettings.setIndexOnContainedResources(appProperties.getEnable_index_contained_resource()); @@ -397,7 +392,7 @@ public class FhirServerConfigCommon { } private Integer resolveInlineResourceThreshold(AppProperties appProperties) { - Integer inlineResourceThreshold = appProperties.getInline_resource_storage_below_size(); + Integer inlineResourceThreshold = appProperties.getBinary_storage_minimum_binary_size(); if (inlineResourceThreshold == null && appProperties.getBinary_storage_mode() == AppProperties.BinaryStorageMode.FILESYSTEM) { return DEFAULT_FILESYSTEM_INLINE_THRESHOLD; From 7d482e30c954bcbdd8bdecfd8f6a74269b851896 Mon Sep 17 00:00:00 2001 From: Patrick Werner Date: Mon, 16 Feb 2026 11:59:08 +0100 Subject: [PATCH 06/11] refactor: rename inline_resource_storage_below_size to binary_storage_minimum_binary_size in AppProperties and application.yaml --- .../java/ca/uhn/fhir/jpa/starter/AppProperties.java | 10 +++++----- src/main/resources/application.yaml | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index 085b15f..31e7828 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -69,7 +69,7 @@ public class AppProperties { private BinaryStorageMode binary_storage_mode = BinaryStorageMode.DATABASE; private String binary_storage_filesystem_base_directory; - private Integer inline_resource_storage_below_size; + private Integer binary_storage_minimum_binary_size; private Boolean bulk_export_enabled = false; private Boolean bulk_import_enabled = false; private Boolean default_pretty_print = true; @@ -511,12 +511,12 @@ public class AppProperties { this.binary_storage_filesystem_base_directory = binary_storage_filesystem_base_directory; } - public Integer getInline_resource_storage_below_size() { - return inline_resource_storage_below_size; + public Integer getBinary_storage_minimum_binary_size() { + return binary_storage_minimum_binary_size; } - public void setInline_resource_storage_below_size(Integer inline_resource_storage_below_size) { - this.inline_resource_storage_below_size = inline_resource_storage_below_size; + public void setBinary_storage_minimum_binary_size(Integer binary_storage_minimum_binary_size) { + this.binary_storage_minimum_binary_size = binary_storage_minimum_binary_size; } public Boolean getBulk_export_enabled() { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 07e3961..32aa6fa 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -424,12 +424,12 @@ hapi: # validation: # requests_enabled: true # responses_enabled: true - # binary_storage_enabled: true - # binary_storage_mode: FILESYSTEM - # binary_storage_filesystem_base_directory: /binstore + binary_storage_enabled: true + binary_storage_mode: FILESYSTEM + binary_storage_filesystem_base_directory: /binstore # When binary_storage_mode is FILESYSTEM and this value is not set, # the starter defaults to 102400 bytes so smaller binaries stay inline. - inline_resource_storage_below_size: 4000 + binary_storage_minimum_binary_size: 4000 # ------------------------------------------------------------------------------- # P. Remote Terminology Service (disabled by default) From f01b86e6c0c3e7d9c3911a6c8fad43dd5aa4e1bd Mon Sep 17 00:00:00 2001 From: Patrick Werner Date: Mon, 16 Feb 2026 11:59:13 +0100 Subject: [PATCH 07/11] refactor: rename inline_resource_storage_below_size to binary_storage_minimum_binary_size in AppProperties and application.yaml --- .../common/FhirServerConfigCommonBinaryStorageTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java index bad6417..58dad82 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java @@ -48,7 +48,7 @@ class FhirServerConfigCommonBinaryStorageTest { void filesystemModeHonoursExplicitMinimum() throws Exception { AppProperties props = new AppProperties(); props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); - props.setInline_resource_storage_below_size(4096); + props.setBinary_storage_minimum_binary_size(4096); Path baseDir = tempDir.resolve("fs-min-explicit"); Files.createDirectories(baseDir); props.setBinary_storage_filesystem_base_directory(baseDir.toString()); @@ -62,7 +62,7 @@ class FhirServerConfigCommonBinaryStorageTest { void filesystemModeSupportsZeroMinimumWhenExplicit() throws Exception { AppProperties props = new AppProperties(); props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); - props.setInline_resource_storage_below_size(0); + props.setBinary_storage_minimum_binary_size(0); Path baseDir = tempDir.resolve("fs-zero"); Files.createDirectories(baseDir); props.setBinary_storage_filesystem_base_directory(baseDir.toString()); From d45bf8027e995e3f1982dbedd9bba599be2fda63 Mon Sep 17 00:00:00 2001 From: Patrick Werner Date: Mon, 16 Feb 2026 12:17:14 +0100 Subject: [PATCH 08/11] removed testing config --- src/main/resources/application.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 32aa6fa..3e30ade 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -424,9 +424,9 @@ hapi: # validation: # requests_enabled: true # responses_enabled: true - binary_storage_enabled: true - binary_storage_mode: FILESYSTEM - binary_storage_filesystem_base_directory: /binstore + # binary_storage_enabled: true + # binary_storage_mode: FILESYSTEM + # binary_storage_filesystem_base_directory: /binstore # When binary_storage_mode is FILESYSTEM and this value is not set, # the starter defaults to 102400 bytes so smaller binaries stay inline. binary_storage_minimum_binary_size: 4000 From eaabef8be02556be85606be9850cb89457a31bd0 Mon Sep 17 00:00:00 2001 From: Patrick Werner Date: Mon, 16 Feb 2026 12:27:26 +0100 Subject: [PATCH 09/11] refactor: update inline_resource_storage_below_size to binary_storage_minimum_binary_size in BinaryStorageIntegrationTest --- .../ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java index 54ab520..da21a14 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java @@ -294,7 +294,7 @@ class BinaryStorageFilesystemDefaultIT extends BaseBinaryStorageIntegrationTest "hapi.fhir.binary_storage_enabled=true", "hapi.fhir.binary_storage_mode=FILESYSTEM", "hapi.fhir.binary_storage_filesystem_base_directory=target/test-binary-storage/filesystem-custom", - "hapi.fhir.inline_resource_storage_below_size=32768" + "hapi.fhir.binary_storage_minimum_binary_size=32768" } ) class BinaryStorageFilesystemCustomThresholdIT extends BaseBinaryStorageIntegrationTest { From fbb67dd978b4f6bef881ba2f71574ab1a3c2b438 Mon Sep 17 00:00:00 2001 From: Steve Swinsburg Date: Mon, 16 Feb 2026 22:33:00 +1100 Subject: [PATCH 10/11] make the bulk export retention time configurable (#917) * #710 make the bulk export retention time configurable * #710 spotless fix --------- Co-authored-by: Steve Swinsburg --- .../java/ca/uhn/fhir/jpa/starter/AppProperties.java | 10 ++++++++++ .../jpa/starter/common/FhirServerConfigCommon.java | 6 ++++++ src/main/resources/application-cds.yaml | 1 + src/main/resources/application.yaml | 1 + 4 files changed, 18 insertions(+) diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index 085b15f..96a4af2 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -131,6 +131,8 @@ public class AppProperties { private Integer expunge_thread_count = null; private Elasticsearch elasticsearch = null; + private Integer bulk_export_file_retention_period_hours = 2; + public List getCustomInterceptorClasses() { return custom_interceptor_classes; } @@ -856,6 +858,14 @@ public class AppProperties { this.elasticsearch = elasticsearch; } + public Integer getBulk_export_file_retention_period_hours() { + return bulk_export_file_retention_period_hours; + } + + public void setBulk_export_file_retention_period_hours(Integer bulk_export_file_retention_period_hours) { + this.bulk_export_file_retention_period_hours = bulk_export_file_retention_period_hours; + } + public static class Cors { private Boolean allow_Credentials = true; private List allowed_origin = List.of("*"); diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java index 88c5874..e16a194 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java @@ -302,6 +302,12 @@ public class FhirServerConfigCommon { jpaStorageSettings.setHSearchIndexPrefix(indexPrefix != null ? indexPrefix : ""); } + // Configure the bulk export file retention period + if (appProperties.getBulk_export_file_retention_period_hours() != null) { + jpaStorageSettings.setBulkExportFileRetentionPeriodHours( + appProperties.getBulk_export_file_retention_period_hours()); + } + return jpaStorageSettings; } diff --git a/src/main/resources/application-cds.yaml b/src/main/resources/application-cds.yaml index 2b0f4d7..31394df 100644 --- a/src/main/resources/application-cds.yaml +++ b/src/main/resources/application-cds.yaml @@ -267,6 +267,7 @@ hapi: # ------------------------------------------------------------------------------- bulk_export_enabled: false bulk_import_enabled: false + bulk_export_file_retention_period_hours: 2 # ------------------------------------------------------------------------------- # F. Write / Delete / Integrity diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 07e3961..44cd922 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -291,6 +291,7 @@ hapi: # ------------------------------------------------------------------------------- bulk_export_enabled: false bulk_import_enabled: false + bulk_export_file_retention_period_hours: 2 # ------------------------------------------------------------------------------- # F. Write / Delete / Integrity From bf50be7584f1f8c08aa80f3a33a2edf496a32aad Mon Sep 17 00:00:00 2001 From: Steve Swinsburg Date: Mon, 16 Feb 2026 22:43:48 +1100 Subject: [PATCH 11/11] This updates the readme to describe another way of running the application using --spring.config.additional-location for override files that don't need to be a copy of the application.yaml (#916) Co-authored-by: Steve Swinsburg --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 96b11b8..7ec71d2 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,25 @@ docker run -p 8090:8080 -v $(pwd)/yourLocalFolder:/configs -e "--spring.config.l ``` Here, the configuration file (*another.application.yaml*) is placed locally in the folder *yourLocalFolder*. - - ``` docker run -p 8090:8080 -e "--spring.config.location=classpath:/another.application.yaml" hapiproject/hapi:latest ``` Here, the configuration file (*another.application.yaml*) is part of the compiled set of resources. +### Configuration with additional override files + +You can layer additional configuration files on top of the default application.yaml while preserving all the base settings. This approach allows you to create specific override files for different environments without duplicating the entire configuration. + +```bash +# Using Maven +mvn spring-boot:run -Dspring-boot.run.arguments="--spring.config.additional-location=classpath:your-overrides.yaml" + +# Using Docker +docker run -p 8080:8080 -e "--spring.config.additional-location=classpath:your-overrides.yaml" hapiproject/hapi:latest +``` + +Here, the additional configuration file (*your-overrides.yaml*) contains only the specific properties you want to override or add, while all default values from application.yaml remain unchanged. + ### One-liner for quickly getting an Implementation Guide installed into HAPI ```