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/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
```
diff --git a/pom.xml b/pom.xml
index 5e3f4b4..9edf05e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -42,10 +42,6 @@
${project.parent.version}-${hapi.fhir.jpa.server.starter.revision}
war
-
- 3.8.3
-
-
HAPI FHIR JPA Server - Starter Project
@@ -541,6 +537,28 @@
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+ 3.6.2
+
+
+ enforce-maven
+
+ enforce
+
+
+
+
+ 3.8.3
+
+
+
+
+
+
+
org.apache.maven.plugins
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..0960197 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;
@@ -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;
}
@@ -511,12 +513,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() {
@@ -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/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/FhirServerConfigCommon.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java
index 88c5874..81ca23e 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());
@@ -302,6 +297,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;
}
@@ -397,7 +398,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;
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 504b85c..79ee250 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;
@@ -50,6 +49,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 +149,7 @@ public class StarterJpaConfig {
@Primary
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
+ Optional elasticsearchSvc,
JpaProperties theJpaProperties,
DataSource myDataSource,
ConfigurableListableBeanFactory myConfigurableListableBeanFactory,
@@ -321,7 +322,6 @@ public class StarterJpaConfig {
Optional corsInterceptor,
IInterceptorBroadcaster interceptorBroadcaster,
Optional binaryAccessProvider,
- BinaryStorageInterceptor binaryStorageInterceptor,
IValidatorModule validatorModule,
Optional graphQLProvider,
BulkDataExportProvider bulkDataExportProvider,
@@ -453,7 +453,6 @@ public class StarterJpaConfig {
// Binary Storage
if (appProperties.getBinary_storage_enabled() && binaryAccessProvider.isPresent()) {
fhirServer.registerProvider(binaryAccessProvider.get());
- fhirServer.registerInterceptor(binaryStorageInterceptor);
}
// Validation
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/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/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-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..70abc71 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
@@ -270,6 +291,7 @@ hapi:
# -------------------------------------------------------------------------------
bulk_export_enabled: false
bulk_import_enabled: false
+ bulk_export_file_retention_period_hours: 2
# -------------------------------------------------------------------------------
# F. Write / Delete / Integrity
@@ -408,7 +430,7 @@ hapi:
# 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)
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 {
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));
+ }
}
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());
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");
+ }
+ }
+}