Merge remote-tracking branch 'origin/master' into rel_8_7-tracking

This commit is contained in:
dotasek
2026-02-17 12:16:19 -05:00
17 changed files with 842 additions and 88 deletions

1
.gitignore vendored
View File

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

View File

@@ -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
```

26
pom.xml
View File

@@ -42,10 +42,6 @@
<version>${project.parent.version}-${hapi.fhir.jpa.server.starter.revision}</version>
<packaging>war</packaging>
<prerequisites>
<maven>3.8.3</maven>
</prerequisites>
<name>HAPI FHIR JPA Server - Starter Project</name>
<repositories>
@@ -541,6 +537,28 @@
</configuration>
</plugin>
<!-- Enforce minimum Maven version requirement -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<id>enforce-maven</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireMavenVersion>
<version>3.8.3</version>
</requireMavenVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<!-- This is to run the integration tests -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>

View File

@@ -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<String> 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<String> allowed_origin = List.of("*");

View File

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

View File

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

View File

@@ -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<ElasticsearchBootSvcImpl> elasticsearchSvc,
JpaProperties theJpaProperties,
DataSource myDataSource,
ConfigurableListableBeanFactory myConfigurableListableBeanFactory,
@@ -321,7 +322,6 @@ public class StarterJpaConfig {
Optional<CorsInterceptor> corsInterceptor,
IInterceptorBroadcaster interceptorBroadcaster,
Optional<BinaryAccessProvider> binaryAccessProvider,
BinaryStorageInterceptor binaryStorageInterceptor,
IValidatorModule validatorModule,
Optional<GraphQLProvider> 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

View File

@@ -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<ObservationJson> observationDocumentResponse =
myRestHighLevelClient.search(searchRequest, ObservationJson.class);
myElasticsearchClient.search(searchRequest, ObservationJson.class);
List<Hit<ObservationJson>> 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));
}
/**

View File

@@ -0,0 +1,29 @@
package ca.uhn.fhir.jpa.starter.validation;
import ca.uhn.fhir.context.FhirContext;
import org.hl7.fhir.common.hapi.validation.support.ValidationSupportChain;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
/**
* Configuration that enables versioned URL fallback behavior for FHIR validation.
*
* This wraps the validation support chain to add fallback logic for versioned canonical URLs.
* When a versioned URL like "http://hl7.org/fhir/StructureDefinition/Organization|4.0.1"
* cannot be found, it will automatically fall back to Non-versioned URL (without the |version suffix)
*
* This is useful when Implementation Guides reference versioned base FHIR resources
* that aren't loaded with exact version matching.
*/
@Configuration
public class VersionedUrlFallbackConfig {
private static final Logger ourLog = LoggerFactory.getLogger(VersionedUrlFallbackConfig.class);
public VersionedUrlFallbackConfig(FhirContext theFhirContext, ValidationSupportChain theValidationSupportChain) {
ourLog.info("Adding VersionedUrlFallbackValidationSupport to validation chain");
theValidationSupportChain.addValidationSupport(
0, new VersionedUrlFallbackValidationSupport(theFhirContext, theValidationSupportChain));
}
}

View File

@@ -0,0 +1,122 @@
package ca.uhn.fhir.jpa.starter.validation;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.support.IValidationSupport;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Set;
import java.util.function.Function;
/**
* A validation support that provides fallback behavior for versioned canonical URLs.
*
* When a versioned URL like "http://hl7.org/fhir/StructureDefinition/Organization|4.0.1"
* is requested, this support first tries the exact versioned URL, then falls back to
* the non-versioned URL if not found.
*
* For non-versioned URLs or URLs not matching the configured prefixes, this support
* returns null to let other supports in the chain handle the request.
*
* This addresses issues where profiles reference versioned base FHIR resources that
* aren't available with exact version matching in the validation context.
*/
// TODO: this should be fixed in core
public class VersionedUrlFallbackValidationSupport implements IValidationSupport {
private static final Logger ourLog = LoggerFactory.getLogger(VersionedUrlFallbackValidationSupport.class);
private final FhirContext myFhirContext;
private final IValidationSupport myChain;
private final Set<String> myUrlPrefixes;
/**
* Creates a fallback validation support that only applies to URLs starting with the default prefix
* (http://hl7.org/fhir/StructureDefinition/).
*/
public VersionedUrlFallbackValidationSupport(FhirContext theFhirContext, IValidationSupport theChain) {
this(theFhirContext, theChain, Set.of(URL_PREFIX_STRUCTURE_DEFINITION));
}
/**
* Creates a fallback validation support that only applies to URLs starting with the specified prefixes.
*
* @param theFhirContext the FHIR context
* @param theChain the validation support chain to delegate fallback lookups to
* @param theUrlPrefixes the URL prefixes to apply fallback logic to (e.g., "http://hl7.org/fhir/StructureDefinition/").
* Pass an empty set to apply to all URLs.
*/
public VersionedUrlFallbackValidationSupport(
FhirContext theFhirContext, IValidationSupport theChain, Set<String> theUrlPrefixes) {
myFhirContext = theFhirContext;
myChain = theChain;
myUrlPrefixes = theUrlPrefixes;
}
@Override
public FhirContext getFhirContext() {
return myFhirContext;
}
@Override
public <T extends IBaseResource> T fetchResource(Class<T> theClass, String theUri) {
return doFetchWithFallback(theUri, uri -> myChain.fetchResource(theClass, uri));
}
@Override
public IBaseResource fetchStructureDefinition(String theUrl) {
return doFetchWithFallback(theUrl, myChain::fetchStructureDefinition);
}
private <T extends IBaseResource> T doFetchWithFallback(String theUrl, Function<String, T> theFetcher) {
// Check if this is a versioned URL (contains |)
int pipeIndex = theUrl.indexOf('|');
if (pipeIndex <= 0) {
// Not a versioned URL, let other supports handle it
return null;
}
String baseUrl = theUrl.substring(0, pipeIndex);
// Check if this URL matches our configured prefixes
if (!matchesPrefix(baseUrl)) {
return null;
}
// Try exact versioned URL first
T result = theFetcher.apply(theUrl);
if (result != null) {
return result;
}
// Try non-versioned URL fallback
result = theFetcher.apply(baseUrl);
if (result != null) {
ourLog.warn(
"Requested versioned canonical '{}' not found, falling back to non-versioned '{}'",
theUrl,
baseUrl);
return result;
}
return null;
}
private boolean matchesPrefix(String theUrl) {
if (myUrlPrefixes.isEmpty()) {
return true;
}
for (String prefix : myUrlPrefixes) {
if (theUrl.startsWith(prefix)) {
return true;
}
}
return false;
}
@Override
public String getName() {
return "VersionedUrlFallbackValidationSupport";
}
}

View File

@@ -267,6 +267,7 @@ hapi:
# -------------------------------------------------------------------------------
bulk_export_enabled: false
bulk_import_enabled: false
bulk_export_file_retention_period_hours: 2
# -------------------------------------------------------------------------------
# F. Write / Delete / Integrity

View File

@@ -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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -0,0 +1,447 @@
package ca.uhn.fhir.jpa.starter.validation;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.support.DefaultProfileValidationSupport;
import ca.uhn.fhir.context.support.IValidationSupport;
import ca.uhn.fhir.validation.FhirValidator;
import ca.uhn.fhir.validation.ValidationResult;
import org.hl7.fhir.common.hapi.validation.support.CommonCodeSystemsTerminologyService;
import org.hl7.fhir.common.hapi.validation.support.InMemoryTerminologyServerValidationSupport;
import org.hl7.fhir.common.hapi.validation.support.ValidationSupportChain;
import org.hl7.fhir.common.hapi.validation.validator.FhirInstanceValidator;
import org.hl7.fhir.r4.model.Observation;
import org.hl7.fhir.r4.model.StructureDefinition;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Set;
import static ca.uhn.fhir.context.support.IValidationSupport.URL_PREFIX_STRUCTURE_DEFINITION;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class VersionedUrlFallbackValidationSupportTest {
private static final String BASE_FHIR_SD_PREFIX = "http://hl7.org/fhir/StructureDefinition/";
private static final String ORGANIZATION_URL = BASE_FHIR_SD_PREFIX + "Organization";
private static final String ORGANIZATION_URL_VERSIONED = ORGANIZATION_URL + "|4.0.1";
private static final String CUSTOM_SD_URL = "http://example.com/StructureDefinition/MyProfile";
private static final String CUSTOM_SD_URL_VERSIONED = CUSTOM_SD_URL + "|1.0.0";
private FhirContext myFhirContext;
@Mock
private IValidationSupport myChain;
private VersionedUrlFallbackValidationSupport mySvc;
@BeforeEach
void setUp() {
myFhirContext = FhirContext.forR4Cached();
mySvc = new VersionedUrlFallbackValidationSupport(myFhirContext, myChain);
}
@Test
void testExactVersionedUrl_ReturnedWithoutFallback() {
// Setup: exact versioned URL is available
StructureDefinition sd = new StructureDefinition();
sd.setUrl(ORGANIZATION_URL);
sd.setVersion("4.0.1");
when(myChain.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED)).thenReturn(sd);
// Execute
var result = mySvc.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED);
// Verify: returns exact match, no fallback attempted
assertNotNull(result);
assertSame(sd, result);
verify(myChain).fetchStructureDefinition(ORGANIZATION_URL_VERSIONED);
verify(myChain, never()).fetchStructureDefinition(ORGANIZATION_URL);
}
@Test
void testFallbackToNonVersionedUrl() {
// Setup: exact versioned URL not found, non-versioned returns a resource
StructureDefinition sd = new StructureDefinition();
sd.setUrl(ORGANIZATION_URL);
when(myChain.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED)).thenReturn(null);
when(myChain.fetchStructureDefinition(ORGANIZATION_URL)).thenReturn(sd);
// Execute
var result = mySvc.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED);
// Verify: fallback to non-versioned succeeds
assertNotNull(result);
assertSame(sd, result);
verify(myChain).fetchStructureDefinition(ORGANIZATION_URL_VERSIONED);
verify(myChain).fetchStructureDefinition(ORGANIZATION_URL);
}
@Test
void testNoFallback_ForNonVersionedUrl() {
// Execute: non-versioned URL should pass through without any chain calls
var result = mySvc.fetchStructureDefinition(ORGANIZATION_URL);
// Verify: returns null immediately, lets other chain supports handle it
assertNull(result);
verifyNoInteractions(myChain);
}
@Test
void testNoFallback_ForCustomUrlNotMatchingDefaultPrefix() {
// Execute: custom URL doesn't match the default prefix filter
var result = mySvc.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED);
// Verify: returns null, doesn't attempt fallback (not in prefix list)
assertNull(result);
verifyNoInteractions(myChain);
}
@Test
void testFallback_ForCustomUrl_WhenPrefixConfigured() {
// Setup: configure to also handle custom URLs
mySvc = new VersionedUrlFallbackValidationSupport(myFhirContext, myChain,
Set.of(BASE_FHIR_SD_PREFIX, "http://example.com/StructureDefinition/"));
StructureDefinition sd = new StructureDefinition();
sd.setUrl(CUSTOM_SD_URL);
when(myChain.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED)).thenReturn(null);
when(myChain.fetchStructureDefinition(CUSTOM_SD_URL)).thenReturn(sd);
// Execute
var result = mySvc.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED);
// Verify
assertNotNull(result);
assertSame(sd, result);
}
@Test
void testFallback_ForAllUrls_WhenEmptyPrefixSet() {
// Setup: empty prefix set means apply to all URLs
mySvc = new VersionedUrlFallbackValidationSupport(myFhirContext, myChain, Set.of());
StructureDefinition sd = new StructureDefinition();
sd.setUrl(CUSTOM_SD_URL);
when(myChain.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED)).thenReturn(null);
when(myChain.fetchStructureDefinition(CUSTOM_SD_URL)).thenReturn(sd);
// Execute
var result = mySvc.fetchStructureDefinition(CUSTOM_SD_URL_VERSIONED);
// Verify
assertNotNull(result);
assertSame(sd, result);
}
@Test
void testFetchResource_FallbackToNonVersioned() {
// Setup
StructureDefinition sd = new StructureDefinition();
sd.setUrl(ORGANIZATION_URL);
when(myChain.fetchResource(StructureDefinition.class, ORGANIZATION_URL_VERSIONED)).thenReturn(null);
when(myChain.fetchResource(StructureDefinition.class, ORGANIZATION_URL)).thenReturn(sd);
// Execute
var result = mySvc.fetchResource(StructureDefinition.class, ORGANIZATION_URL_VERSIONED);
// Verify
assertNotNull(result);
assertSame(sd, result);
}
@Test
void testFetchResource_NoFallbackForNonMatchingPrefix() {
// Execute
var result = mySvc.fetchResource(StructureDefinition.class, CUSTOM_SD_URL_VERSIONED);
// Verify
assertNull(result);
verifyNoInteractions(myChain);
}
@Test
void testFetchResource_NoFallbackForNonVersionedUrl() {
// Execute
var result = mySvc.fetchResource(StructureDefinition.class, ORGANIZATION_URL);
// Verify
assertNull(result);
verifyNoInteractions(myChain);
}
@Test
void testReturnsNull_WhenNoFallbackSucceeds() {
// Setup: nothing found in any lookup
when(myChain.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED)).thenReturn(null);
when(myChain.fetchStructureDefinition(ORGANIZATION_URL)).thenReturn(null);
// Execute
var result = mySvc.fetchStructureDefinition(ORGANIZATION_URL_VERSIONED);
// Verify
assertNull(result);
verify(myChain).fetchStructureDefinition(ORGANIZATION_URL_VERSIONED);
verify(myChain).fetchStructureDefinition(ORGANIZATION_URL);
}
@Test
void testGetName() {
assertEquals("VersionedUrlFallbackValidationSupport", mySvc.getName());
}
@Test
void testGetFhirContext() {
assertSame(myFhirContext, mySvc.getFhirContext());
}
@Test
void testDefaultUrlPrefix() {
assertEquals("http://hl7.org/fhir/StructureDefinition/",
URL_PREFIX_STRUCTURE_DEFINITION);
}
/**
* Integration tests using real DefaultProfileValidationSupport instead of mocks.
* This tests the actual fallback behavior with FHIR's built-in profiles.
*/
@Nested
class WithRealValidationChain {
private FhirContext myFhirContext;
private ValidationSupportChain myValidationChain;
private VersionedUrlFallbackValidationSupport mySvc;
@BeforeEach
void setUp() {
myFhirContext = FhirContext.forR4Cached();
// Create a validation chain with the real DefaultProfileValidationSupport
// which contains all built-in FHIR R4 StructureDefinitions
myValidationChain = new ValidationSupportChain(new DefaultProfileValidationSupport(myFhirContext));
// Wrap the chain with our fallback support, similar to production setup
mySvc = new VersionedUrlFallbackValidationSupport(myFhirContext, myValidationChain);
}
@Test
void testFallbackToNonVersionedUrl_WithRealDefaultProfile() {
// The DefaultProfileValidationSupport has Organization without version suffix.
// When we request versioned URL, it should fall back and find it.
String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Organization|4.0.1";
var result = mySvc.fetchStructureDefinition(versionedUrl);
assertNotNull(result, "Should find Organization via fallback to non-versioned URL");
assertInstanceOf(StructureDefinition.class, result);
StructureDefinition sd = (StructureDefinition) result;
assertEquals("http://hl7.org/fhir/StructureDefinition/Organization", sd.getUrl());
assertEquals("Organization", sd.getName());
}
@Test
void testFallbackForPatient_WithRealDefaultProfile() {
String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Patient|4.0.1";
var result = mySvc.fetchStructureDefinition(versionedUrl);
assertNotNull(result, "Should find Patient via fallback");
assertInstanceOf(StructureDefinition.class, result);
StructureDefinition sd = (StructureDefinition) result;
assertEquals("http://hl7.org/fhir/StructureDefinition/Patient", sd.getUrl());
}
@Test
void testFetchResource_WithRealDefaultProfile() {
String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Observation|4.0.1";
var result = mySvc.fetchResource(StructureDefinition.class, versionedUrl);
assertNotNull(result, "Should find Observation via fetchResource fallback");
assertEquals("http://hl7.org/fhir/StructureDefinition/Observation", result.getUrl());
}
@Test
void testNonExistentResource_ReturnsNull() {
String versionedUrl = "http://hl7.org/fhir/StructureDefinition/NonExistentResource|1.0.0";
var result = mySvc.fetchStructureDefinition(versionedUrl);
assertNull(result, "Should return null for non-existent resource");
}
@Test
void testNonVersionedUrl_PassesThrough() {
// Non-versioned URLs should return null from the fallback support
// (they're handled by DefaultProfileValidationSupport directly in a real chain)
String nonVersionedUrl = "http://hl7.org/fhir/StructureDefinition/Patient";
var result = mySvc.fetchStructureDefinition(nonVersionedUrl);
// The fallback support returns null for non-versioned URLs
// In a real setup, the chain would handle this
assertNull(result);
}
@Test
void testDataTypeProfiles_WithRealDefaultProfile() {
// Test that data type StructureDefinitions also work
String versionedUrl = "http://hl7.org/fhir/StructureDefinition/HumanName|4.0.1";
var result = mySvc.fetchStructureDefinition(versionedUrl);
assertNotNull(result, "Should find HumanName data type via fallback");
assertInstanceOf(StructureDefinition.class, result);
assertEquals("http://hl7.org/fhir/StructureDefinition/HumanName",
((StructureDefinition) result).getUrl());
}
}
/**
* Integration tests where VersionedUrlFallbackValidationSupport is part of the
* ValidationSupportChain (as in production) rather than wrapping it.
*/
@Nested
class WithFallbackInChain {
private FhirContext myFhirContext;
private ValidationSupportChain myValidationChain;
@BeforeEach
void setUp() {
myFhirContext = FhirContext.forR4Cached();
DefaultProfileValidationSupport defaultSupport = new DefaultProfileValidationSupport(myFhirContext);
// Create a chain where VersionedUrlFallbackValidationSupport is a member
// This mimics production setup where the fallback is part of the chain
myValidationChain = new ValidationSupportChain(defaultSupport);
VersionedUrlFallbackValidationSupport fallbackSupport =
new VersionedUrlFallbackValidationSupport(myFhirContext, myValidationChain);
// Rebuild chain with fallback support first (higher priority)
myValidationChain = new ValidationSupportChain(fallbackSupport, defaultSupport);
}
@Test
void testChainResolvesVersionedUrl() {
String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Patient|4.0.1";
var result = myValidationChain.fetchStructureDefinition(versionedUrl);
assertNotNull(result, "Chain should resolve versioned URL via fallback");
assertInstanceOf(StructureDefinition.class, result);
assertEquals("http://hl7.org/fhir/StructureDefinition/Patient",
((StructureDefinition) result).getUrl());
}
@Test
void testChainResolvesNonVersionedUrl() {
// Non-versioned URLs should still work (handled by DefaultProfileValidationSupport)
String nonVersionedUrl = "http://hl7.org/fhir/StructureDefinition/Patient";
var result = myValidationChain.fetchStructureDefinition(nonVersionedUrl);
assertNotNull(result, "Chain should resolve non-versioned URL directly");
assertInstanceOf(StructureDefinition.class, result);
}
@Test
void testChainFetchResource() {
String versionedUrl = "http://hl7.org/fhir/StructureDefinition/Encounter|4.0.1";
var result = myValidationChain.fetchResource(StructureDefinition.class, versionedUrl);
assertNotNull(result, "fetchResource should work through chain with fallback");
assertEquals("http://hl7.org/fhir/StructureDefinition/Encounter", result.getUrl());
}
@Test
void testMultipleResourceTypes() {
// Verify fallback works for various resource types
String[] versionedUrls = {
"http://hl7.org/fhir/StructureDefinition/Condition|4.0.1",
"http://hl7.org/fhir/StructureDefinition/Medication|4.0.1",
"http://hl7.org/fhir/StructureDefinition/DiagnosticReport|4.0.1"
};
for (String versionedUrl : versionedUrls) {
var result = myValidationChain.fetchStructureDefinition(versionedUrl);
assertNotNull(result, "Should resolve " + versionedUrl);
}
}
}
/**
* Full validation integration tests using FhirInstanceValidator to prove
* the fallback support works in actual resource validation scenarios.
*/
@Nested
class WithFhirInstanceValidator {
private FhirContext myFhirContext;
private FhirValidator myValidator;
@BeforeEach
void setUp() {
myFhirContext = FhirContext.forR4Cached();
DefaultProfileValidationSupport defaultSupport = new DefaultProfileValidationSupport(myFhirContext);
InMemoryTerminologyServerValidationSupport terminologySupport =
new InMemoryTerminologyServerValidationSupport(myFhirContext);
CommonCodeSystemsTerminologyService commonCodeSystems =
new CommonCodeSystemsTerminologyService(myFhirContext);
// Build production-like validation chain with fallback support
ValidationSupportChain baseChain = new ValidationSupportChain(
defaultSupport,
terminologySupport,
commonCodeSystems
);
VersionedUrlFallbackValidationSupport fallbackSupport =
new VersionedUrlFallbackValidationSupport(myFhirContext, baseChain);
// ValidationSupportChain now handles caching internally (since HAPI FHIR 8.0.0)
ValidationSupportChain fullChain = new ValidationSupportChain(
fallbackSupport,
defaultSupport,
terminologySupport,
commonCodeSystems
);
FhirInstanceValidator instanceValidator = new FhirInstanceValidator(fullChain);
myValidator = myFhirContext.newValidator();
myValidator.registerValidatorModule(instanceValidator);
}
@Test
void testValidateSimpleObservation() {
Observation observation = new Observation();
observation.setStatus(Observation.ObservationStatus.FINAL);
observation.getCode().addCoding()
.setSystem("http://loinc.org")
.setCode("12345-6")
.setDisplay("Test");
ValidationResult result = myValidator.validateWithResult(observation);
// The validation should complete without errors related to unresolved versioned URLs
assertNotNull(result);
// We don't require the resource to be fully valid (may have other issues)
// but it should not fail due to missing versioned profile resolution
assertTrue(result.getMessages().stream()
.noneMatch(m -> m.getMessage().contains("Unable to locate profile")),
"Should not have profile resolution errors");
}
}
}