From f1d7621c40cf5f4cabf78c743444b911bb8a69de Mon Sep 17 00:00:00 2001 From: Brenin Rhodes Date: Tue, 26 Aug 2025 13:51:52 -0600 Subject: [PATCH 01/20] Update CR to 3.26.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 80b4fc1..632a6fe 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ 17 1 - 3.24.0 + 3.26.0 From 49b3d31a8f8308c6a7aba5a641eeaf8bcc45d707 Mon Sep 17 00:00:00 2001 From: dotasek Date: Tue, 26 Aug 2025 17:25:27 -0400 Subject: [PATCH 02/20] Update hapi.fhir.jpa.server.starter.revision to 2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 632a6fe..57ed7b8 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 17 - 1 + 2 3.26.0 From 8621c0d89ca0176fedde84d0c0e95d096fe7d9d1 Mon Sep 17 00:00:00 2001 From: Jens Kristian Villadsen Date: Wed, 27 Aug 2025 08:52:59 +0200 Subject: [PATCH 03/20] Feat/extra resource loading from npm (#784) * Logic to do the basics * Added configurable folders * Scoped additional resources to - pr. IG * Updated telemetry version * Changed to set * Changed PostConstruct to Component and introduced use of PartitionCondition Added try-load of HAPI optimized dialect * Satisfying spotless * Reverted to default config * Apply spotless * Use core-provided feature Addling the binary content as its needed for example resource inspection * Update src/main/java/ca/uhn/fhir/jpa/starter/util/JpaHibernatePropertiesProvider.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Reverting dialect change - fit for a potential other PR * no message * Formatting * Update src/main/java/ca/uhn/fhir/jpa/starter/ig/ExtendedPackageInstallationSpec.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Bad AI ... bad bad AI ... * Formatting * bump of feature revision flag --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Dockerfile | 2 +- .../uhn/fhir/jpa/starter/AppProperties.java | 19 ++++-- .../common/FhirServerConfigCommon.java | 5 -- .../common/OnPartitionModeEnabled.java | 18 ++++++ .../common/PartitionModeConfigurer.java | 59 ++++++++----------- .../jpa/starter/common/StarterJpaConfig.java | 42 +++++++++---- .../ig/ExtendedPackageInstallationSpec.java | 24 ++++++++ src/main/resources/application.yaml | 12 ++-- 8 files changed, 121 insertions(+), 60 deletions(-) create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/common/OnPartitionModeEnabled.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/ig/ExtendedPackageInstallationSpec.java diff --git a/Dockerfile b/Dockerfile index 806c542..7fefc67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM docker.io/library/maven:3.9.9-eclipse-temurin-17 AS build-hapi WORKDIR /tmp/hapi-fhir-jpaserver-starter -ARG OPENTELEMETRY_JAVA_AGENT_VERSION=1.33.3 +ARG OPENTELEMETRY_JAVA_AGENT_VERSION=2.13.1 RUN curl -LSsO https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OPENTELEMETRY_JAVA_AGENT_VERSION}/opentelemetry-javaagent.jar COPY pom.xml . 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 c053d62..f1913e1 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -5,7 +5,7 @@ import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings.ClientIdStrategyEnum; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings.IdStrategyEnum; import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel; -import ca.uhn.fhir.jpa.packages.PackageInstallationSpec; +import ca.uhn.fhir.jpa.starter.ig.ExtendedPackageInstallationSpec; import ca.uhn.fhir.rest.api.EncodingEnum; import org.hl7.fhir.r4.model.Bundle; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -88,7 +88,9 @@ public class AppProperties { private Partitioning partitioning = null; private Boolean validate_resource_status_for_package_upload = true; private Boolean install_transitive_ig_dependencies = true; - private Map implementationGuides = null; + + private List install_additional_resources_from_ig_folders = new ArrayList<>(); + private Map implementationGuides = null; private String custom_content_path = null; private String app_content_path = null; private Boolean lastn_enabled = false; @@ -157,11 +159,11 @@ public class AppProperties { this.defer_indexing_for_codesystems_of_size = defer_indexing_for_codesystems_of_size; } - public Map getImplementationGuides() { + public Map getImplementationGuides() { return implementationGuides; } - public void setImplementationGuides(Map implementationGuides) { + public void setImplementationGuides(Map implementationGuides) { this.implementationGuides = implementationGuides; } @@ -710,6 +712,15 @@ public class AppProperties { this.resource_dbhistory_enabled = resource_dbhistory_enabled; } + public List getInstall_additional_resources_from_ig_folders() { + return install_additional_resources_from_ig_folders; + } + + public void setInstall_additional_resources_from_ig_folders( + List install_additional_resources_from_ig_folders) { + this.install_additional_resources_from_ig_folders = install_additional_resources_from_ig_folders; + } + public Boolean getPre_expand_value_sets() { return this.pre_expand_value_sets; } 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 c3728b8..924bc04 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 @@ -325,11 +325,6 @@ public class FhirServerConfigCommon { return retVal; } - @Bean - public PartitionModeConfigurer partitionModeConfigurer() { - return new PartitionModeConfigurer(); - } - @Primary @Bean public HibernatePropertiesProvider jpaStarterDialectProvider( diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/OnPartitionModeEnabled.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/OnPartitionModeEnabled.java new file mode 100644 index 0000000..b695bab --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/OnPartitionModeEnabled.java @@ -0,0 +1,18 @@ +package ca.uhn.fhir.jpa.starter.common; + +import ca.uhn.fhir.jpa.starter.AppProperties; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +public class OnPartitionModeEnabled implements Condition { + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + var appProperties = Binder.get(context.getEnvironment()) + .bind("hapi.fhir", AppProperties.class) + .orElse(null); + if (appProperties == null) return false; + return appProperties.getPartitioning() != null; + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/PartitionModeConfigurer.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/PartitionModeConfigurer.java index c83e8fc..dbf5f0f 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/PartitionModeConfigurer.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/PartitionModeConfigurer.java @@ -1,6 +1,5 @@ package ca.uhn.fhir.jpa.starter.common; -import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.jpa.interceptor.PatientIdPartitionInterceptor; import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.partition.PartitionManagementProvider; @@ -9,48 +8,36 @@ import ca.uhn.fhir.jpa.starter.AppProperties; import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.fhir.rest.server.interceptor.partition.RequestTenantPartitionInterceptor; import ca.uhn.fhir.rest.server.tenant.UrlBaseTenantIdentificationStrategy; -import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Conditional; +import org.springframework.stereotype.Component; +@Component +@Conditional({OnPartitionModeEnabled.class}) public class PartitionModeConfigurer { private static final Logger ourLog = LoggerFactory.getLogger(PartitionModeConfigurer.class); - @Autowired - private AppProperties myAppProperties; + public PartitionModeConfigurer( + AppProperties myAppProperties, + ISearchParamExtractor mySearchParamExtractor, + PartitionSettings myPartitionSettings, + RestfulServer myRestfulServer, + PartitionManagementProvider myPartitionManagementProvider) { - @Autowired - private FhirContext myFhirContext; - - @Autowired - private ISearchParamExtractor mySearchParamExtractor; - - @Autowired - private PartitionSettings myPartitionSettings; - - @Autowired - private RestfulServer myRestfulServer; - - @Autowired - private PartitionManagementProvider myPartitionManagementProvider; - - @PostConstruct - public void start() { - if (myAppProperties.getPartitioning() != null) { - if (myAppProperties.getPartitioning().getPatient_id_partitioning_mode() == Boolean.TRUE) { - ourLog.info("Partitioning mode enabled in: Patient ID partitioning mode"); - PatientIdPartitionInterceptor patientIdInterceptor = - new PatientIdPartitionInterceptor(myFhirContext, mySearchParamExtractor, myPartitionSettings); - myRestfulServer.registerInterceptor(patientIdInterceptor); - myPartitionSettings.setUnnamedPartitionMode(true); - } else if (myAppProperties.getPartitioning().getRequest_tenant_partitioning_mode() == Boolean.TRUE) { - ourLog.info("Partitioning mode enabled in: Request tenant partitioning mode"); - myRestfulServer.registerInterceptor(new RequestTenantPartitionInterceptor()); - myRestfulServer.setTenantIdentificationStrategy(new UrlBaseTenantIdentificationStrategy()); - } - - myRestfulServer.registerProviders(myPartitionManagementProvider); + var partitioning = myAppProperties.getPartitioning(); + if (partitioning.getPatient_id_partitioning_mode()) { + ourLog.info("Partitioning mode enabled in: Patient ID partitioning mode"); + var patientIdInterceptor = new PatientIdPartitionInterceptor( + myRestfulServer.getFhirContext(), mySearchParamExtractor, myPartitionSettings); + myRestfulServer.registerInterceptor(patientIdInterceptor); + myPartitionSettings.setUnnamedPartitionMode(true); + } else if (partitioning.getRequest_tenant_partitioning_mode()) { + ourLog.info("Partitioning mode enabled in: Request tenant partitioning mode"); + myRestfulServer.registerInterceptor(new RequestTenantPartitionInterceptor()); + myRestfulServer.setTenantIdentificationStrategy(new UrlBaseTenantIdentificationStrategy()); } + + myRestfulServer.registerProviders(myPartitionManagementProvider); } } 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 2344f12..dc71733 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 @@ -9,6 +9,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirVersionEnum; import ca.uhn.fhir.context.support.IValidationSupport; import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; +import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.jpa.api.IDaoRegistry; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.config.ThreadPoolFactoryConfig; @@ -20,6 +21,7 @@ import ca.uhn.fhir.jpa.config.util.HapiEntityManagerFactoryUtil; import ca.uhn.fhir.jpa.config.util.ResourceCountCacheUtil; import ca.uhn.fhir.jpa.dao.FulltextSearchSvcImpl; import ca.uhn.fhir.jpa.dao.IFulltextSearchSvc; +import ca.uhn.fhir.jpa.dao.TransactionProcessor; import ca.uhn.fhir.jpa.dao.search.HSearchSortHelperImpl; import ca.uhn.fhir.jpa.dao.search.IHSearchSortHelper; import ca.uhn.fhir.jpa.delete.ThreadSafeResourceDeleterSvc; @@ -29,8 +31,9 @@ import ca.uhn.fhir.jpa.interceptor.UserRequestRetryVersionConflictsInterceptor; import ca.uhn.fhir.jpa.interceptor.validation.RepositoryValidatingInterceptor; import ca.uhn.fhir.jpa.ips.provider.IpsOperationProvider; import ca.uhn.fhir.jpa.model.config.SubscriptionSettings; +import ca.uhn.fhir.jpa.packages.AdditionalResourcesParser; +import ca.uhn.fhir.jpa.packages.IHapiPackageCacheManager; import ca.uhn.fhir.jpa.packages.IPackageInstallerSvc; -import ca.uhn.fhir.jpa.packages.PackageInstallationSpec; import ca.uhn.fhir.jpa.provider.DaoRegistryResourceSupportedSvc; import ca.uhn.fhir.jpa.provider.DiffProvider; import ca.uhn.fhir.jpa.provider.IJpaSystemProvider; @@ -47,6 +50,7 @@ import ca.uhn.fhir.jpa.starter.AppProperties; import ca.uhn.fhir.jpa.starter.annotations.OnCorsPresent; import ca.uhn.fhir.jpa.starter.annotations.OnImplementationGuidesPresent; import ca.uhn.fhir.jpa.starter.common.validation.IRepositoryValidationInterceptorFactory; +import ca.uhn.fhir.jpa.starter.ig.ExtendedPackageInstallationSpec; import ca.uhn.fhir.jpa.starter.ig.IImplementationGuideOperationProvider; import ca.uhn.fhir.jpa.starter.util.EnvironmentHelper; import ca.uhn.fhir.jpa.subscription.util.SubscriptionDebugLogInterceptor; @@ -55,6 +59,7 @@ import ca.uhn.fhir.mdm.provider.MdmProviderLoader; import ca.uhn.fhir.narrative.DefaultThymeleafNarrativeGenerator; import ca.uhn.fhir.narrative2.NullNarrativeGenerator; import ca.uhn.fhir.rest.api.IResourceSupportedSvc; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.rest.openapi.OpenApiInterceptor; import ca.uhn.fhir.rest.server.ApacheProxyAddressStrategy; import ca.uhn.fhir.rest.server.ETagSupportEnum; @@ -74,6 +79,7 @@ import ca.uhn.fhir.validation.IValidatorModule; import ca.uhn.fhir.validation.ResultSeverityEnum; import com.google.common.base.Strings; import jakarta.persistence.EntityManagerFactory; +import org.hl7.fhir.instance.model.api.IBaseBundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.NoSuchBeanDefinitionException; @@ -93,11 +99,8 @@ import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.web.cors.CorsConfiguration; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.io.IOException; +import java.util.*; import javax.sql.DataSource; import static ca.uhn.fhir.jpa.starter.common.validation.IRepositoryValidationInterceptorFactory.ENABLE_REPOSITORY_VALIDATING_INTERCEPTOR; @@ -207,14 +210,18 @@ public class StarterJpaConfig { public IPackageInstallerSvc packageInstaller( AppProperties appProperties, IPackageInstallerSvc packageInstallerSvc, - Batch2JobRegisterer batch2JobRegisterer) { + Batch2JobRegisterer batch2JobRegisterer, + FhirContext fhirContext, + TransactionProcessor transactionProcessor, + IHapiPackageCacheManager iHapiPackageCacheManager) + throws IOException { batch2JobRegisterer.start(); if (appProperties.getImplementationGuides() != null) { - Map guides = appProperties.getImplementationGuides(); - for (Map.Entry guidesEntry : guides.entrySet()) { - PackageInstallationSpec packageInstallationSpec = guidesEntry.getValue(); + Map guides = appProperties.getImplementationGuides(); + for (Map.Entry guidesEntry : guides.entrySet()) { + ExtendedPackageInstallationSpec packageInstallationSpec = guidesEntry.getValue(); if (appProperties.getInstall_transitive_ig_dependencies()) { packageInstallationSpec @@ -223,7 +230,22 @@ public class StarterJpaConfig { .addDependencyExclude("hl7.fhir.r4.core") .addDependencyExclude("hl7.fhir.r5.core"); } + packageInstallerSvc.install(packageInstallationSpec); + + Set extraResources = packageInstallationSpec.getAdditionalResourceFolders(); + packageInstallationSpec.setPackageContents(iHapiPackageCacheManager + .loadPackageContents(packageInstallationSpec.getName(), packageInstallationSpec.getVersion()) + .getBytes()); + + if (extraResources != null && !extraResources.isEmpty()) { + IBaseBundle transaction = AdditionalResourcesParser.bundleAdditionalResources( + extraResources, packageInstallationSpec, fhirContext); + transactionProcessor.transaction( + new SystemRequestDetails().setRequestPartitionId(RequestPartitionId.defaultPartition()), + transaction, + false); + } } } return packageInstallerSvc; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/ig/ExtendedPackageInstallationSpec.java b/src/main/java/ca/uhn/fhir/jpa/starter/ig/ExtendedPackageInstallationSpec.java new file mode 100644 index 0000000..1237650 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/ig/ExtendedPackageInstallationSpec.java @@ -0,0 +1,24 @@ +package ca.uhn.fhir.jpa.starter.ig; + +import ca.uhn.fhir.jpa.packages.PackageInstallationSpec; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Set; + +public class ExtendedPackageInstallationSpec extends PackageInstallationSpec { + + public Set getAdditionalResourceFolders() { + return additionalResourceFolders; + } + + public void setAdditionalResourceFolders(Set additionalResourceFolders) { + this.additionalResourceFolders = additionalResourceFolders; + } + + @Schema( + description = + "Specifies folder names containing additional resources to load. These folders will be scanned for resources to include during installation.") + @JsonProperty("additionalResourceFolders") + private Set additionalResourceFolders; +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ed0dc64..b769a62 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -171,10 +171,14 @@ hapi: # reloadExisting: false # installMode: STORE_AND_INSTALL # example not from registry - # ips_1_0_0: - # packageUrl: https://build.fhir.org/ig/HL7/fhir-ips/package.tgz - # name: hl7.fhir.uv.ips - # version: 1.0.0 + # ips_1_0_0: + # packageUrl: https://costateixeira.github.io/smart-ips-pilgrimage-fulltest/package.tgz + # name: smart.who.int.ips-pilgrimage-test + # version: 0.1.0 + # installMode: STORE_AND_INSTALL + # additionalResourceFolders: + # - example + # - example2 # supported_resource_types: # - Patient # - Observation From 5585170c7d2e05dfaa2e2e8192c4d46757bd40c9 Mon Sep 17 00:00:00 2001 From: Martin Bernstorff Date: Tue, 16 Sep 2025 22:05:08 +0200 Subject: [PATCH 04/20] ci: remove success message from formatting check (#858) * ci: remove success message from formatting check The workflow succeeds giving a green check-mark next to the commit id. Perhaps writing a comment each time is redundant? E.g.: https://github.com/hapifhir/hapi-fhir-jpaserver-starter/pull/856 * Update spotless check workflow to handle failures Modify condition for PR comment action to run only on failure. --- .github/workflows/spotless-check.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spotless-check.yml b/.github/workflows/spotless-check.yml index f4e616f..6fb1bbe 100644 --- a/.github/workflows/spotless-check.yml +++ b/.github/workflows/spotless-check.yml @@ -20,11 +20,12 @@ jobs: - name: spotless:check run: mvn spotless:check - uses: mshick/add-pr-comment@v2 - if: always() + # Only run if the previous step failed + if: failure() && steps.spotless_check.outcome == 'failure' with: proxy-url: https://slack-bots.azure.smilecdr.com/robogary/github message-success: | - Formatting check succeeded! + Formatting check succeeded! This should never run, because the step is skipped on workflow success. message-failure: | **This Pull Request has failed the formatting check** From 680255ff6260b4110404528124380d2957a91a9c Mon Sep 17 00:00:00 2001 From: Jens Kristian Villadsen Date: Wed, 17 Sep 2025 06:51:19 +0200 Subject: [PATCH 05/20] Feature/mcp (#846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added MCP support using SSE on http://localhost:8080/sse * Reverted change that IntelliJ complains about * Pre-rework * Cleaned up the code a fair bit * Renamed * Renamed * Running spotless * Reuse FhirContext in result serialization to make MCP server work with R5 * Added support for transactions * PoC tool for CDS Hooks * some cleanup * Upgrade of model protocol * Added comments * Removed field injection ... CDS to be changed to AutoConfig eventually * Adjusted to new builder pattern * Update src/main/java/ca/uhn/fhir/rest/server/MCPBridge.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * A bit of restructuring * More rework * Removing (suspected unnecessary) formatting * Add more example doc * Added a smoke- / passthrough-test * Applied spotless * Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/ToolFactory.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Formatting * Added some documentation * spotless cares about MD? * Reverting back to default values * minor refinements * Fixed CDS hooks configuration * Fixed some wirings * Revert "Fixed some wirings" This reverts commit c9d3bc0b3b6756d7b15f5d2cf6100c99784fb868. * Revert "Fixed CDS hooks configuration" This reverts commit 67c4279100bf14432c164906235ea6348ee8af22. --------- Co-authored-by: Ádám Z. Kövér Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .dockerignore | 1 - README.md | 4 + pom.xml | 26 ++ .../uhn/fhir/jpa/starter/AppProperties.java | 2 +- .../starter/mcp/CallToolResultFactory.java | 40 +++ .../uhn/fhir/jpa/starter/mcp/Interaction.java | 34 ++ .../fhir/jpa/starter/mcp/McpServerConfig.java | 81 +++++ .../fhir/jpa/starter/mcp/RequestBuilder.java | 120 +++++++ .../uhn/fhir/jpa/starter/mcp/ToolFactory.java | 337 ++++++++++++++++++ .../ca/uhn/fhir/rest/server/McpBridge.java | 9 + .../ca/uhn/fhir/rest/server/McpCdsBridge.java | 117 ++++++ .../uhn/fhir/rest/server/McpFhirBridge.java | 101 ++++++ src/main/resources/application.yaml | 58 +++ .../ca/uhn/fhir/jpa/starter/McpTests.java | 78 ++++ .../resources/mcp/hello-patient-request.json | 18 + .../resources/mcp/mcp-hookContext-object.json | 5 + .../resources/mcp/mpc-prefetch-object.json | 9 + .../mcp/plandefinition-hello-patient.xml | 42 +++ 18 files changed, 1080 insertions(+), 2 deletions(-) create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/mcp/CallToolResultFactory.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/mcp/Interaction.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/mcp/ToolFactory.java create mode 100644 src/main/java/ca/uhn/fhir/rest/server/McpBridge.java create mode 100644 src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java create mode 100644 src/main/java/ca/uhn/fhir/rest/server/McpFhirBridge.java create mode 100644 src/test/java/ca/uhn/fhir/jpa/starter/McpTests.java create mode 100644 src/test/resources/mcp/hello-patient-request.json create mode 100644 src/test/resources/mcp/mcp-hookContext-object.json create mode 100644 src/test/resources/mcp/mpc-prefetch-object.json create mode 100644 src/test/resources/mcp/plandefinition-hello-patient.xml diff --git a/.dockerignore b/.dockerignore index ea1e38e..8124151 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,6 @@ target/maven-* target/ROOT target/test-classes/ target/war -target/duplicate-finder-result.xml target/jacoco.exec target/*.original .idea diff --git a/README.md b/README.md index a5d58a1..711450e 100644 --- a/README.md +++ b/README.md @@ -588,3 +588,7 @@ docker run --rm -it -p 8080:8080 \ ``` You can configure the agent using environment variables or Java system properties, see for details. + +## Enable MCP + +MCP capabilities can be enabled by setting the `spring.ai.mcp.server.enabled` to `true`. This will enable the MCP server and expose the MCP endpoints. The MCP endpoint is currently hardcoded to `/mcp/message` and can be tried out by running e.g. `npx @modelcontextprotocol/inspector` and connect to http://localhost:8080/mcp/message using Streamable HTTP. Spring AI MCP Server Auto Configuration is currently not supported. diff --git a/pom.xml b/pom.xml index 57ed7b8..3373897 100644 --- a/pom.xml +++ b/pom.xml @@ -383,6 +383,32 @@ 5.0.1 + + org.springframework.ai + spring-ai-mcp + 1.0.2 + + + + + + io.modelcontextprotocol.sdk + mcp + 0.12.1 + + + + org.springframework + spring-test + + org.junit.jupiter 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 f1913e1..f10f693 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -21,9 +21,9 @@ import java.util.Set; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +@EnableConfigurationProperties @ConfigurationProperties(prefix = "hapi.fhir") @Configuration -@EnableConfigurationProperties public class AppProperties { private final Set auto_version_reference_at_paths = new HashSet<>(); diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/CallToolResultFactory.java b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/CallToolResultFactory.java new file mode 100644 index 0000000..72b744b --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/CallToolResultFactory.java @@ -0,0 +1,40 @@ +package ca.uhn.fhir.jpa.starter.mcp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpSchema; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class CallToolResultFactory { + + public static McpSchema.CallToolResult success( + String resourceType, Interaction interaction, String response, int status) { + Map payload = Map.of( + "resourceType", resourceType, + "interaction", interaction, + "response", response, + "status", status); + + ObjectMapper objectMapper = new ObjectMapper(); + String jacksonData; + try { + jacksonData = objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + return McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent(jacksonData)) + .build(); + } + + public static McpSchema.CallToolResult failure(String message) { + return McpSchema.CallToolResult.builder() + .isError(true) + .addTextContent(message) + .build(); + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/Interaction.java b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/Interaction.java new file mode 100644 index 0000000..e377d33 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/Interaction.java @@ -0,0 +1,34 @@ +package ca.uhn.fhir.jpa.starter.mcp; + +import ca.uhn.fhir.rest.api.RequestTypeEnum; + +public enum Interaction { + CALL_CDS_HOOK("call-cds-hook"), + SEARCH("search"), + READ("read"), + CREATE("create"), + UPDATE("update"), + DELETE("delete"), + PATCH("patch"), + TRANSACTION("transaction"); + + private final String name; + + Interaction(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public RequestTypeEnum asRequestType() { + return switch (this) { + case SEARCH, READ -> RequestTypeEnum.GET; + case CREATE, TRANSACTION, CALL_CDS_HOOK -> RequestTypeEnum.POST; + case UPDATE -> RequestTypeEnum.PUT; + case DELETE -> RequestTypeEnum.DELETE; + case PATCH -> RequestTypeEnum.PATCH; + }; + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java new file mode 100644 index 0000000..9dec687 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java @@ -0,0 +1,81 @@ +package ca.uhn.fhir.jpa.starter.mcp; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.McpBridge; +import ca.uhn.fhir.rest.server.McpCdsBridge; +import ca.uhn.fhir.rest.server.McpFhirBridge; +import ca.uhn.fhir.rest.server.RestfulServer; +import ca.uhn.hapi.fhir.cdshooks.api.ICdsServiceRegistry; +import ca.uhn.hapi.fhir.cdshooks.module.CdsHooksObjectMapperFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +// https://mcp-cn.ssshooter.com/sdk/java/mcp-server#sse-servlet +// https://www.baeldung.com/spring-ai-model-context-protocol-mcp +// https://github.com/spring-projects/spring-ai-examples/blob/main/model-context-protocol/weather/manual-webflux-server/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java +// https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-stdio-server/src/main/java/org/springframework/ai/mcp/sample/server +// https://github.com/spring-projects/spring-ai-examples/blob/main/model-context-protocol/sampling/mcp-weather-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java +// https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html +@Configuration +@ConditionalOnProperty( + prefix = "spring.ai.mcp.server", + name = {"enabled"}, + havingValue = "true") +public class McpServerConfig { + + private static final String SSE_ENDPOINT = "/sse"; + private static final String SSE_MESSAGE_ENDPOINT = "/mcp/message"; + + @Bean + public McpSyncServer syncServer( + List mcpBridges, McpStreamableServerTransportProvider transportProvider) { + return McpServer.sync(transportProvider) + .tools(mcpBridges.stream() + .flatMap(bridge -> bridge.generateTools().stream()) + .toList()) + .build(); + } + + @Bean + public McpFhirBridge mcpFhirBridge(RestfulServer restfulServer) { + return new McpFhirBridge(restfulServer); + } + + @Bean + @ConditionalOnProperty( + prefix = "hapi.fhir.cr", + name = {"enabled"}, + havingValue = "true") + public McpCdsBridge mcpCdsBridge(FhirContext fhirContext, ICdsServiceRegistry cdsServiceRegistry) { + + return new McpCdsBridge( + fhirContext, cdsServiceRegistry, new CdsHooksObjectMapperFactory(fhirContext).newMapper()); + } + + @Bean + public HttpServletStreamableServerTransportProvider servletSseServerTransportProvider( + /*McpServerProperties properties*/ ) { + + return HttpServletStreamableServerTransportProvider.builder() + .disallowDelete(false) + .mcpEndpoint(SSE_MESSAGE_ENDPOINT) + .objectMapper(new ObjectMapper()) + // .contextExtractor((serverRequest, context) -> context) + .build(); + } + + @Bean + public ServletRegistrationBean customServletBean( + HttpServletStreamableServerTransportProvider transportProvider /*, McpServerProperties properties*/) { + return new ServletRegistrationBean<>(transportProvider, SSE_MESSAGE_ENDPOINT, SSE_ENDPOINT); + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java new file mode 100644 index 0000000..985e711 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java @@ -0,0 +1,120 @@ +package ca.uhn.fhir.jpa.starter.mcp; + +import ca.uhn.fhir.context.FhirContext; +import com.google.gson.Gson; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public class RequestBuilder { + + private final FhirContext fhirContext; + private final String resourceType; + private final Interaction interaction; + private final Map config; + /** + * Constructs a RequestBuilder for a specific FHIR interaction. + * + * @param fhirContext the FHIR context + * @param contextMap a map containing configuration parameters, including 'resourceType' + * @param interaction the type of interaction (e.g., SEARCH, READ, CREATE, etc.) + */ + public RequestBuilder(FhirContext fhirContext, Map contextMap, Interaction interaction) { + this.config = contextMap; + if (interaction == Interaction.TRANSACTION) this.resourceType = ""; + else if (contextMap.get("resourceType") instanceof String rt && !rt.isBlank()) this.resourceType = rt; + else throw new IllegalArgumentException("Missing or invalid 'resourceType' in contextMap"); + + this.interaction = interaction; + this.fhirContext = fhirContext; + } + + public MockHttpServletRequest buildRequest() { + String basePath = "/" + resourceType; + String method; + MockHttpServletRequest req; + + switch (interaction) { + case SEARCH -> { + method = "GET"; + req = new MockHttpServletRequest(method, basePath); + Map sp = null; + if (config.get("query") instanceof Map q) { + sp = q; + } else if (config.get("searchParams") instanceof Map s) { + sp = s; + } + if (sp != null) { + sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString())); + } + } + case READ -> { + method = "GET"; + String id = requireString(); + req = new MockHttpServletRequest(method, basePath + "/" + id); + } + case CREATE, TRANSACTION -> { + method = "POST"; + req = new MockHttpServletRequest(method, basePath); + applyResourceBody(req); + } + case UPDATE -> { + method = "PUT"; + String id = requireString(); + req = new MockHttpServletRequest(method, basePath + "/" + id); + applyResourceBody(req); + } + case DELETE -> { + method = "DELETE"; + String id = requireString(); + req = new MockHttpServletRequest(method, basePath + "/" + id); + } + case PATCH -> { + method = "PATCH"; + String id = requireString(); + req = new MockHttpServletRequest(method, basePath + "/" + id); + applyPatchBody(req); + } + default -> throw new IllegalArgumentException("Unsupported interaction: " + interaction); + } + + req.setContentType("application/fhir+json"); + req.addHeader("Accept", "application/fhir+json"); + return req; + } + + private void applyResourceBody(MockHttpServletRequest req) { + Object resourceObj = config.get("resource"); + String json; + if (resourceObj instanceof Map) json = new Gson().toJson(resourceObj, Map.class); + else if (resourceObj instanceof String) json = resourceObj.toString(); + else throw new IllegalArgumentException("Unsupported resource body type: " + resourceObj.getClass()); + req.setContent(json.getBytes(StandardCharsets.UTF_8)); + } + + private void applyPatchBody(MockHttpServletRequest req) { + Object patchBody = config.get("resource"); + if (patchBody == null) { + throw new IllegalArgumentException("Missing 'resource' for patch interaction"); + } + String content; + if (patchBody instanceof String s) { + content = s; + } else if (patchBody instanceof IBaseResource r) { + content = fhirContext.newJsonParser().encodeResourceToString(r); + } else { + throw new IllegalArgumentException("Unsupported patch body type: " + patchBody.getClass()); + } + req.setContent(content.getBytes(StandardCharsets.UTF_8)); + } + + private String requireString() { + Object val = config.get("id"); + if (!(val instanceof String s) || s.isBlank()) { + throw new IllegalArgumentException("Missing or invalid '" + "id" + "'"); + } + return s; + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/ToolFactory.java b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/ToolFactory.java new file mode 100644 index 0000000..32cd005 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/ToolFactory.java @@ -0,0 +1,337 @@ +package ca.uhn.fhir.jpa.starter.mcp; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.Tool; + +public class ToolFactory { + + private static final String READ_FHIR_RESOURCE_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "type of the resource to read" + }, + "id": { + "type": "string", + "description": "id of the resource to read" + } + } + + } + """; + + private static final String CREATE_FHIR_RESOURCE_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "Type of the resource to create" + }, + "resource": { + "type": "object", + "description": "Resource content in JSON format" + }, + "headers": { + "type": "object", + "description": "Headers for create request.\\nAvailable headers: If-None-Exist header for conditional create where the value is search param string.\\nFor example: {\\"If-None-Exist\\": \\"active=false\\"}" + } + }, + "required": ["resourceType", "resource"] + } + """; + + private static final String UPDATE_FHIR_RESOURCE_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "Type of the resource to update" + }, + "id": { + "type": "string", + "description": "ID of the resource to update" + }, + "resource": { + "type": "object", + "description": "Updated resource content in JSON format" + } + }, + "required": ["resourceType", "id", "resource"] + } + """; + + private static final String CONDITIONAL_UPDATE_FHIR_RESOURCE_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "Type of the resource to update" + }, + "resource": { + "type": "object", + "description": "Updated resource content in JSON format" + }, + "query": { + "type": "string", + "description": "Query string with search params separate by \\",\\". For example: \\"_id=pt-1,name=ivan\\". Uses for conditional update." + }, + "headers": { + "type": "object", + "description": "Headers for create request.\\nAvailable headers: If-None-Match header for conditional update where the value is ETag.\\nFor example: {\\"If-None-Match\\": \\"12345\\"}" + } + }, + "required": ["resourceType", "resource"] + } + """; + + private static final String CONDITIONAL_PATCH_FHIR_RESOURCE_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "Type of the resource to patch" + }, + "resource": { + "type": "object", + "description": "Resource content to patch in JSON format" + }, + "query": { + "type": "string", + "description": "Query string with search params separate by \\",\\". For example: \\"_id=pt-1,name=ivan\\". Uses for conditional patch." + }, + "headers": { + "type": "object", + "description": "Headers for create request.\\nAvailable headers: If-None-Match header for conditional patch where the value is ETag.\\nFor example: {\\"If-None-Match\\": \\"12345\\"}" + } + }, + "required": ["resourceType", "resource"] + } + """; + + private static final String PATCH_FHIR_RESOURCE_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "Type of the resource to patch" + }, + "id": { + "type": "string", + "description": "ID of the resource to patch" + }, + "resource": { + "type": "object", + "description": "Resource content to patch in JSON format" + } + }, + "required": ["resourceType", "id", "resource"] + } + """; + + private static final String DELETE_FHIR_RESOURCE_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "Type of the resource to delete" + }, + "id": { + "type": "string", + "description": "ID of the resource to delete" + } + }, + "required": ["resourceType", "id"] + } + """; + + private static final String SEARCH_FHIR_RESOURCES_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "Type of the resource to search" + }, + "query": { + "type": "string", + "description": "Query string with search params separate by \\",\\". For example: \\"_id=pt-1,name=ivan\\"" + } + }, + "required": ["resourceType", "query"] + } + """; + + private static final String CREATE_FHIR_TRANSACTION_SCHEMA = + """ + { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "description": "A Bundle resource type with type 'transaction' containing multiple FHIR resources" + }, + "resource": { + "type": "object", + "description": "A FHIR Bundle Resource content in JSON format" + } + }, + "required": ["resourceType", "resource"] + } + """; + + // TODO Add a tool for the CDS Hooks discovery endpoint + // Alternatively, should each service be a separate tool? + + // TODO Add other fields from https://cds-hooks.hl7.org/STU2/#http-request-1 + // TODO Context here is for the patient-view hook, https://cds-hooks.hl7.org/hooks/STU1/patient-view.html#context + private static final String CALL_CDS_HOOK_SCHEMA_2_0_1 = + """ + { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "The CDS Service to call." + }, + "hook": { + "type": "string", + "description": "The hook that triggered this CDS Service call." + }, + "hookInstance": { + "type": "string", + "description": "A universally unique identifier (UUID) for this particular hook call." + }, + "hookContext": { + "type": "object", + "description": "Hook-specific contextual data that the CDS service will need.", + "properties": { + "userId": { + "type": "string", + "description": "The id of the current user. Must be in the format [ResourceType]/[id]." + }, + "patientId": { + "type": "string", + "description": "The FHIR Patient.id of the current patient in context" + }, + "encounterId": { + "type": "string", + "description": "The FHIR Encounter.id of the current encounter in context." + } + } + }, + "prefetch": { + "type": "object", + "description": "Additional data to prefetch for the CDS service call." + } + }, + "required": ["service", "hook", "hookInstance", "hookContext"] + } + """; + + public static Tool readFhirResource() throws JsonProcessingException { + return new Tool.Builder() + .name("read-fhir-resource") + .description("Read an individual FHIR resource") + .inputSchema(mapper.readValue(READ_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool createFhirResource() throws JsonProcessingException { + return new Tool.Builder() + .name("create-fhir-resource") + .description("Create a new FHIR resource") + .inputSchema(mapper.readValue(CREATE_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool updateFhirResource() throws JsonProcessingException { + return new Tool.Builder() + .name("update-fhir-resource") + .description("Update an existing FHIR resource") + .inputSchema(mapper.readValue(UPDATE_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool conditionalUpdateFhirResource() throws JsonProcessingException { + return new Tool.Builder() + .name("conditional-update-fhir-resource") + .description("Conditional update an existing FHIR resource") + .inputSchema(mapper.readValue(CONDITIONAL_UPDATE_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool conditionalPatchFhirResource() throws JsonProcessingException { + return new Tool.Builder() + .name("conditional-patch-fhir-resource") + .description("Conditional patch an existing FHIR resource") + .inputSchema(mapper.readValue(CONDITIONAL_PATCH_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool patchFhirResource() throws JsonProcessingException { + return new Tool.Builder() + .name("patch-fhir-resource") + .description("Patch an existing FHIR resource") + .inputSchema(mapper.readValue(PATCH_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool deleteFhirResource() throws JsonProcessingException { + return new Tool.Builder() + .name("delete-fhir-resource") + .description("Delete an existing FHIR resource") + .inputSchema(mapper.readValue(DELETE_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool searchFhirResources() throws JsonProcessingException { + return new Tool.Builder() + .name("search-fhir-resources") + .description("Search an existing FHIR resources") + .inputSchema(mapper.readValue(SEARCH_FHIR_RESOURCES_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool createFhirTransaction() throws JsonProcessingException { + return new Tool.Builder() + .name("create-fhir-transaction") + .description("Create a FHIR transaction") + .inputSchema(mapper.readValue(CREATE_FHIR_TRANSACTION_SCHEMA, McpSchema.JsonSchema.class)) + .build(); + } + + public static Tool callCdsHook() throws JsonProcessingException { + return new Tool.Builder() + .name("call-cds-hook") + .description("Call a CDS Hook") + .inputSchema(mapper.readValue(CALL_CDS_HOOK_SCHEMA_2_0_1, McpSchema.JsonSchema.class)) + .build(); + } + + public static final ObjectMapper mapper = new ObjectMapper() + .enable(JsonParser.Feature.ALLOW_COMMENTS) + .enable(JsonParser.Feature.ALLOW_SINGLE_QUOTES) + .enable(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES) + .enable(JsonParser.Feature.INCLUDE_SOURCE_IN_LOCATION) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); +} diff --git a/src/main/java/ca/uhn/fhir/rest/server/McpBridge.java b/src/main/java/ca/uhn/fhir/rest/server/McpBridge.java new file mode 100644 index 0000000..b568ec2 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/rest/server/McpBridge.java @@ -0,0 +1,9 @@ +package ca.uhn.fhir.rest.server; + +import io.modelcontextprotocol.server.McpServerFeatures; + +import java.util.List; + +public interface McpBridge { + List generateTools(); +} diff --git a/src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java b/src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java new file mode 100644 index 0000000..1fe1de7 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java @@ -0,0 +1,117 @@ +package ca.uhn.fhir.rest.server; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.starter.cdshooks.CdsHooksRequest; +import ca.uhn.fhir.jpa.starter.mcp.Interaction; +import ca.uhn.fhir.jpa.starter.mcp.ToolFactory; +import ca.uhn.fhir.rest.api.server.cdshooks.CdsServiceRequestContextJson; +import ca.uhn.hapi.fhir.cdshooks.api.ICdsServiceRegistry; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +public class McpCdsBridge implements McpBridge { + + private static final Logger logger = LoggerFactory.getLogger(McpCdsBridge.class); + + private final ICdsServiceRegistry cdsServiceRegistry; + private final ObjectMapper objectMapper; + private final FhirContext fhirContext; + + public McpCdsBridge(FhirContext fhirContext, ICdsServiceRegistry cdsServiceRegistry, ObjectMapper objectMapper) { + this.fhirContext = fhirContext; + this.cdsServiceRegistry = cdsServiceRegistry; + this.objectMapper = objectMapper; + } + + public List generateTools() { + + try { + return List.of(new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.callCdsHook()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.CALL_CDS_HOOK)) + .build()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private McpSchema.CallToolResult getToolResult(McpSchema.CallToolRequest contextMap, Interaction interaction) { + + if (interaction != Interaction.CALL_CDS_HOOK) + throw new UnsupportedOperationException("Unsupported interaction: " + interaction); + + var cdsInvocation = constructCdsHooksRequest(contextMap); + var serviceResponseJson = cdsServiceRegistry.callService( + contextMap.arguments().get("service").toString(), cdsInvocation); + + final String content; + try { + content = objectMapper.writeValueAsString(serviceResponseJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + return McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent(content)) + .build(); + } + + private @NotNull CdsHooksRequest constructCdsHooksRequest(McpSchema.CallToolRequest callToolRequest) { + + // TODO Build up CDS Hooks request JSON from contextMap + var contextMap = callToolRequest.arguments(); + var request = new CdsHooksRequest(); + request.setHook(contextMap.get("hook").toString()); + request.setHookInstance(contextMap.get("hookInstance").toString()); + + // Context + var context = new CdsServiceRequestContextJson(); + Object hookContextObj = contextMap.get("hookContext"); + if (hookContextObj instanceof Map hookContext) { + if (hookContext.containsKey("userId")) { + context.put("userId", String.valueOf(hookContext.get("userId"))); + } + if (hookContext.containsKey("patientId")) { + context.put("patientId", String.valueOf(hookContext.get("patientId"))); + } + if (hookContext.containsKey("encounterId")) { + context.put("encounterId", String.valueOf(hookContext.get("encounterId"))); + } + } + request.setContext(context); + + // Prefetch + if (contextMap.containsKey("prefetch")) { + var prefetch = contextMap.get("prefetch"); + if (prefetch instanceof Map) { + @SuppressWarnings("unchecked") + var prefetchMap = (Map) prefetch; + for (Map.Entry entry : prefetchMap.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + + // Object is a String -> Object map + // Use a standard JSON library to convert it + var resource = fhirContext.newJsonParser().parseResource(new Gson().toJson(value)); + request.addPrefetch(key, resource); + } + } else { + logger.warn( + "Prefetch object is not a Map: {}", + prefetch == null ? "null" : prefetch.getClass().getName()); + } + } + + return request; + } +} diff --git a/src/main/java/ca/uhn/fhir/rest/server/McpFhirBridge.java b/src/main/java/ca/uhn/fhir/rest/server/McpFhirBridge.java new file mode 100644 index 0000000..13c5c3a --- /dev/null +++ b/src/main/java/ca/uhn/fhir/rest/server/McpFhirBridge.java @@ -0,0 +1,101 @@ +package ca.uhn.fhir.rest.server; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.starter.mcp.CallToolResultFactory; +import ca.uhn.fhir.jpa.starter.mcp.Interaction; +import ca.uhn.fhir.jpa.starter.mcp.RequestBuilder; +import ca.uhn.fhir.jpa.starter.mcp.ToolFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class McpFhirBridge implements McpBridge { + + private static final Logger logger = LoggerFactory.getLogger(McpFhirBridge.class); + + private final RestfulServer restfulServer; + private final FhirContext fhirContext; + + public McpFhirBridge(RestfulServer restfulServer) { + this.restfulServer = restfulServer; + this.fhirContext = restfulServer.getFhirContext(); + } + + public List generateTools() { + + try { + return List.of( + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.createFhirResource()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.CREATE)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.readFhirResource()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.READ)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.updateFhirResource()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.UPDATE)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.deleteFhirResource()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.DELETE)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.conditionalPatchFhirResource()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.PATCH)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.searchFhirResources()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.SEARCH)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.conditionalUpdateFhirResource()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.UPDATE)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.patchFhirResource()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.PATCH)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.createFhirTransaction()) + .callHandler((exchange, request) -> getToolResult(request, Interaction.TRANSACTION)) + .build()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private McpSchema.CallToolResult getToolResult(McpSchema.CallToolRequest contextMap, Interaction interaction) { + + var response = new MockHttpServletResponse(); + var request = new RequestBuilder(fhirContext, contextMap.arguments(), interaction).buildRequest(); + + try { + restfulServer.handleRequest(interaction.asRequestType(), request, response); + var status = response.getStatus(); + var body = response.getContentAsString(); + + if (status >= 200 && status < 300) { + if (body.isBlank()) { + return CallToolResultFactory.failure("Empty successful response for " + interaction); + } + + return CallToolResultFactory.success( + contextMap.arguments().get("resourceType").toString(), interaction, body, status); + } else { + return CallToolResultFactory.failure(String.format("FHIR server error %d: %s", status, body)); + } + } catch (Exception e) { + logger.error(e.getMessage(), e); + return CallToolResultFactory.failure("Unexpected error: " + e.getMessage()); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b769a62..662608a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -36,7 +36,65 @@ management: export: enabled: true spring: + ai: + # Run e.g. `npx @modelcontextprotocol/inspector` and connect to http://localhost:8080/mcp/message using Streamable HTTP + +# Add the following to the MCP server settings file in e.g. cursor or claude (Desktop applications) for local debugging: +# cursor: +# { +# "mcpServers": { +# "hapi": { +# "url": "http://localhost:8080/mcp/message" +# } +# } +# } +# or claude: +# { +# "mcpServers": { +# "hapi": { +# "command": "npx", +# "args": [ +# "mcp-remote@latest", +# "http://localhost:8080/mcp/message" +# ] +# } +# } +# } + + mcp: + server: + # Will be enabled once spring-ai-starter-mcp-server is added as dependency +# name: FHIR MCP Server +# version: 1.0.0 +# type: SYNC +# instructions: "This server provides access to a FHIR RESTful API. You can use it to query FHIR resources, perform operations, and retrieve data in a structured format." +# sse-message-endpoint: /mcp/message +# capabilities: +# tool: true +# resource: true +# prompt: true +# completion: true +# stdio: false + enabled: true + + #endpoint: /mcp + + #schema: + # fhir-enabled: true + # fhir: + # base-url: http://localhost:8080/fhir + + #query: + # prompt: + # template: | + # You are a FHIR assistant. Translate the following question into a valid FHIR RESTful API query: + # "{{query}}" + # Use the provided FHIR schema: + # {{schema}} + #base-url: /api/v1 + main: + allow-bean-definition-overriding: false allow-circular-references: true flyway: enabled: false diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/McpTests.java b/src/test/java/ca/uhn/fhir/jpa/starter/McpTests.java new file mode 100644 index 0000000..19a8a5f --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/McpTests.java @@ -0,0 +1,78 @@ +package ca.uhn.fhir.jpa.starter; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.searchparam.config.NicknameServiceConfig; +import ca.uhn.fhir.jpa.starter.mcp.ToolFactory; +import ca.uhn.fhir.util.BundleUtil; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.gson.Gson; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.hl7.fhir.r4.model.Bundle; +import org.junit.jupiter.api.Test; +import org.opencds.cqf.fhir.cr.hapi.config.RepositoryConfig; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class, NicknameServiceConfig.class, RepositoryConfig.class}, properties = {"spring.datasource.url=jdbc:h2:mem:dbr4", "hapi.fhir.fhir_version=r4", "hibernate.search.enabled=true", "spring.ai.mcp.server.enabled=true",}) +public class McpTests { + + @LocalServerPort + private int port; + + @Test + public void mcpTests() throws JsonProcessingException { + + var fhirContext = FhirContext.forR4(); + + var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint("/mcp/message").build(); + var client = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10)).capabilities(McpSchema.ClientCapabilities.builder().roots(true) // Enable roots capability + .sampling().build()).build(); + var initializationResult = client.initialize(); + + var tools = client.listTools().tools(); + assertThat(tools).isNotEmpty(); + + var searchToolName = ToolFactory.searchFhirResources().name(); + var createToolName = ToolFactory.createFhirResource().name(); + + assertThat(tools.stream().filter(tool -> tool.name().equals(searchToolName)).findFirst().get()).isNotNull(); + assertThat(tools.stream().filter(tool -> tool.name().equals(createToolName)).findFirst().get()).isNotNull(); + + + var createMcpRequest = new McpSchema.CallToolRequest.Builder().arguments(Map.of("operation", "create", "resourceType", "Patient", "resource", """ + { + "resourceType": "Patient", + "id": "example", + "identifier": [ + { + "system": "urn:something", + "value": "uncleScrooge" + } + ] + }""")).name(createToolName).build(); + assertThat(client.callTool(createMcpRequest).isError()).isFalse(); + + var searchMcpRequest = new McpSchema.CallToolRequest.Builder().arguments(Map.of("operation", "search", "resourceType", "Patient", "query", "identifier=urn:something|uncleScrooge")).name(searchToolName).build(); + + var searchResult = client.callTool(searchMcpRequest); + assertThat(searchResult.isError()).isFalse(); + assertThat(searchResult.content().size()).isEqualTo(1); + + var content = ((McpSchema.TextContent) searchResult.content().get(0)); + var embeddedResponseBundle = new Gson().fromJson(content.text(), LinkedHashMap.class).get("response"); + var responseBundle = fhirContext.newJsonParser().parseResource(Bundle.class, embeddedResponseBundle.toString()); + var entries = BundleUtil.toListOfEntries(fhirContext, responseBundle); + assertThat(entries.size()).isEqualTo(1); + + client.closeGracefully(); + } +} diff --git a/src/test/resources/mcp/hello-patient-request.json b/src/test/resources/mcp/hello-patient-request.json new file mode 100644 index 0000000..2a26967 --- /dev/null +++ b/src/test/resources/mcp/hello-patient-request.json @@ -0,0 +1,18 @@ +{ + "hook": "patient-view", + "hookInstance": "8d5a3a2e-6d8b-4f7c-bb2d-2f1b8cf1d7a1", + "context": { + "userId": "Practitioner/123", + "patientId": "123", + "encounterId": "456" + }, + "prefetch": { + "item1": { + "resourceType": "Patient", + "gender": "male", + "birthDate": "1989-10-23", + "id": "123", + "active": true + } + } +} diff --git a/src/test/resources/mcp/mcp-hookContext-object.json b/src/test/resources/mcp/mcp-hookContext-object.json new file mode 100644 index 0000000..b4648e6 --- /dev/null +++ b/src/test/resources/mcp/mcp-hookContext-object.json @@ -0,0 +1,5 @@ +{ + "userId": "Practitioner/123", + "patientId": "123", + "encounterId": "456" +} diff --git a/src/test/resources/mcp/mpc-prefetch-object.json b/src/test/resources/mcp/mpc-prefetch-object.json new file mode 100644 index 0000000..4b30088 --- /dev/null +++ b/src/test/resources/mcp/mpc-prefetch-object.json @@ -0,0 +1,9 @@ +{ + "item1": { + "resourceType": "Patient", + "gender": "male", + "birthDate": "1989-10-23", + "id": "123", + "active": true + } +} diff --git a/src/test/resources/mcp/plandefinition-hello-patient.xml b/src/test/resources/mcp/plandefinition-hello-patient.xml new file mode 100644 index 0000000..a9c4aee --- /dev/null +++ b/src/test/resources/mcp/plandefinition-hello-patient.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + <type> + <coding> + <system value="http://terminology.hl7.org/CodeSystem/plan-definition-type" /> + <code value="eca-rule" /> + <display value="ECA Rule" /> + </coding> + </type> + <status value="draft" /> + <experimental value="true" /> + <date value="2024-09-28" /> + <description value="Demo PlanDefinition for Hello Patient" /> + <action> + <title value="Hello, Patient!" /> + <description value="Please state the nature of the medical emergency." /> + <trigger> + <type value="named-event" /> + <name value="patient-view" /> + </trigger> + <condition> + <kind value="applicability" /> + <expression> + <language value="text/cql" /> + <expression value="true" /> + </expression> + </condition> + </action> +</PlanDefinition> From 4265137b126089acfea3827a0c91bf46f73ee9c9 Mon Sep 17 00:00:00 2001 From: chgl <chgl@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:52:35 +0200 Subject: [PATCH 06/20] Switched to the bitnamilegacy image repository (#861) Co-authored-by: chgl <5307555+chgl@users.noreply.github.com> --- Dockerfile | 2 +- charts/hapi-fhir-jpaserver/Chart.lock | 6 +++--- charts/hapi-fhir-jpaserver/Chart.yaml | 6 +++--- charts/hapi-fhir-jpaserver/README.md | 7 ++++--- charts/hapi-fhir-jpaserver/values.yaml | 12 ++++++++++-- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7fefc67..542354d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN mkdir /app && cp /tmp/hapi-fhir-jpaserver-starter/target/ROOT.war /app/main. ########### bitnami tomcat version is suitable for debugging and comes with a shell ########### it can be built using eg. `docker build --target tomcat .` -FROM bitnami/tomcat:10.1 AS tomcat +FROM docker.io/bitnamilegacy/tomcat:10.1.43-debian-12-r0 AS tomcat USER root RUN rm -rf /opt/bitnami/tomcat/webapps/ROOT && \ diff --git a/charts/hapi-fhir-jpaserver/Chart.lock b/charts/hapi-fhir-jpaserver/Chart.lock index c4e5b45..a109ac3 100644 --- a/charts/hapi-fhir-jpaserver/Chart.lock +++ b/charts/hapi-fhir-jpaserver/Chart.lock @@ -1,9 +1,9 @@ dependencies: - name: postgresql repository: oci://registry-1.docker.io/bitnamicharts - version: 16.7.11 + version: 16.7.27 - name: common repository: oci://registry-1.docker.io/bitnamicharts version: 2.31.3 -digest: sha256:e8b5591d28c8b420a68c1bef3ac8530f47c0c9c5d22fddec3c73f45ae5ba615a -generated: "2025-06-16T13:22:01.211160104+02:00" +digest: sha256:4ac06ee8266b694791c14eaef2e0f19a5714ebe4bd19b1bcb4bc6069d8fab482 +generated: "2025-09-22T11:26:51.06616649+02:00" diff --git a/charts/hapi-fhir-jpaserver/Chart.yaml b/charts/hapi-fhir-jpaserver/Chart.yaml index ff22918..e324b40 100644 --- a/charts/hapi-fhir-jpaserver/Chart.yaml +++ b/charts/hapi-fhir-jpaserver/Chart.yaml @@ -7,14 +7,14 @@ sources: - https://github.com/hapifhir/hapi-fhir-jpaserver-starter dependencies: - name: postgresql - version: 16.7.11 + version: 16.7.27 repository: oci://registry-1.docker.io/bitnamicharts condition: postgresql.enabled - name: common repository: oci://registry-1.docker.io/bitnamicharts version: 2.31.3 appVersion: 8.2.0 -version: 0.20.1 +version: 0.21.0 annotations: artifacthub.io/license: Apache-2.0 artifacthub.io/containsSecurityUpdates: "false" @@ -27,4 +27,4 @@ annotations: # When using the list of objects option the valid supported kinds are # added, changed, deprecated, removed, fixed, and security. - kind: changed - description: "fixed typo in README.md" + description: "Use the bitnamilegacy repos for the images. See <https://github.com/bitnami/containers/issues/83267>." diff --git a/charts/hapi-fhir-jpaserver/README.md b/charts/hapi-fhir-jpaserver/README.md index b807fdc..1f2d42a 100644 --- a/charts/hapi-fhir-jpaserver/README.md +++ b/charts/hapi-fhir-jpaserver/README.md @@ -1,6 +1,6 @@ # HAPI FHIR JPA Server Starter Helm Chart -![Version: 0.20.1](https://img.shields.io/badge/Version-0.20.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 8.2.0](https://img.shields.io/badge/AppVersion-8.2.0-informational?style=flat-square) +![Version: 0.21.0](https://img.shields.io/badge/Version-0.21.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 8.2.0](https://img.shields.io/badge/AppVersion-8.2.0-informational?style=flat-square) This helm chart will help you install the HAPI FHIR JPA Server in a Kubernetes environment. @@ -16,7 +16,7 @@ helm install hapi-fhir-jpaserver hapifhir/hapi-fhir-jpaserver | Repository | Name | Version | |------------|------|---------| | oci://registry-1.docker.io/bitnamicharts | common | 2.31.3 | -| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.7.11 | +| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.7.27 | ## Values @@ -62,6 +62,7 @@ helm install hapi-fhir-jpaserver hapifhir/hapi-fhir-jpaserver | postgresql.auth.database | string | `"fhir"` | name for a custom database to create | | postgresql.auth.existingSecret | string | `""` | Name of existing secret to use for PostgreSQL credentials `auth.postgresPassword`, `auth.password`, and `auth.replicationPassword` will be ignored and picked up from this secret The secret must contain the keys `postgres-password` (which is the password for "postgres" admin user), `password` (which is the password for the custom user to create when `auth.username` is set), and `replication-password` (which is the password for replication user). The secret might also contains the key `ldap-password` if LDAP is enabled. `ldap.bind_password` will be ignored and picked from this secret in this case. The value is evaluated as a template. | | postgresql.enabled | bool | `true` | enable an included PostgreSQL DB. see <https://github.com/bitnami/charts/tree/master/bitnami/postgresql> for details if set to `false`, the values under `externalDatabase` are used | +| postgresql.image.repository | string | `"bitnamilegacy/postgresql"` | | | replicaCount | int | `1` | number of replicas to deploy | | resources | object | `{}` | configure the FHIR server's resource requests and limits | | resourcesPreset | string | `"medium"` | set container resources according to one common preset (allowed values: none, nano, micro, small, medium, large, xlarge, 2xlarge). This is ignored if `resources` is set (`resources` is recommended for production). More information: <https://github.com/bitnami/charts/blob/main/bitnami/common/templates/_resources.tpl#L15> | @@ -84,7 +85,7 @@ helm install hapi-fhir-jpaserver hapifhir/hapi-fhir-jpaserver | tests.resourcesPreset | string | `"nano"` | set container resources according to one common preset (allowed values: none, nano, micro, small, medium, large, xlarge, 2xlarge). This is ignored if `resources` is set (`resources` is recommended for production). More information: <https://github.com/bitnami/charts/blob/main/bitnami/common/templates/_resources.tpl#L15> | | tolerations | list | `[]` | pod tolerations | | topologySpreadConstraints | list | `[]` | pod topology spread configuration see: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/#api | -| waitForDatabaseInitContainer.image | object | `{"pullPolicy":"IfNotPresent","registry":"docker.io","repository":"bitnami/postgresql","tag":"17.5.0-debian-12-r11@sha256:ac8dd0d6512c4c5fb146c16b1c5f05862bd5f600d73348506ab4252587e7fcc6"}` | image to use for the init container which waits until the database is ready to accept connections | +| waitForDatabaseInitContainer.image | object | `{"pullPolicy":"IfNotPresent","registry":"docker.io","repository":"bitnamilegacy/postgresql","tag":"17.6.0-debian-12-r4@sha256:926356130b77d5742d8ce605b258d35db9b62f2f8fd1601f9dbaef0c8a710a8d"}` | image to use for the init container which waits until the database is ready to accept connections | ## Development diff --git a/charts/hapi-fhir-jpaserver/values.yaml b/charts/hapi-fhir-jpaserver/values.yaml index ee5db16..a189bca 100644 --- a/charts/hapi-fhir-jpaserver/values.yaml +++ b/charts/hapi-fhir-jpaserver/values.yaml @@ -114,11 +114,19 @@ topologySpreadConstraints: # app.kubernetes.io/instance: hapi-fhir-jpaserver # app.kubernetes.io/name: hapi-fhir-jpaserver +# @ignored +# used by the bitnami sub-chart. Allowing it allows for overrding the image repository. +global: + security: + allowInsecureImages: true + postgresql: # -- enable an included PostgreSQL DB. # see <https://github.com/bitnami/charts/tree/master/bitnami/postgresql> for details # if set to `false`, the values under `externalDatabase` are used enabled: true + image: + repository: bitnamilegacy/postgresql auth: # -- name for a custom database to create database: "fhir" @@ -306,6 +314,6 @@ waitForDatabaseInitContainer: # is ready to accept connections image: registry: docker.io - repository: bitnami/postgresql - tag: 17.5.0-debian-12-r11@sha256:ac8dd0d6512c4c5fb146c16b1c5f05862bd5f600d73348506ab4252587e7fcc6 + repository: bitnamilegacy/postgresql + tag: 17.6.0-debian-12-r4@sha256:926356130b77d5742d8ce605b258d35db9b62f2f8fd1601f9dbaef0c8a710a8d pullPolicy: IfNotPresent From 9576cfa9b5f70fc0afb16783c1fb50878ea28038 Mon Sep 17 00:00:00 2001 From: Jens Kristian Villadsen <jenskristianvilladsen@gmail.com> Date: Mon, 22 Sep 2025 23:41:43 +0200 Subject: [PATCH 07/20] Feature/elastic on boot (#856) * Fixing up elastic for Spring Boot * Adding class shadowing for issue reported on https://github.com/hapifhir/hapi-fhir/pull/7242 * Formatting * corrected condition * fix import * fix2 * crappy fix3 * fix actuator endpoint * Simplified expression * Ironed out a few legacy issues * more rework * major overhaul * Disabling invalid test * Reverting back to defaults for text searches * Added default hibernate settings from the EnvironmentHelper * Formatting * Added comment on class shadow * Added missing default --- pom.xml | 33 +-- .../BaseJpaResourceProviderObservation.java | 108 ++++++++++ .../ca/uhn/fhir/jpa/starter/Application.java | 3 +- .../OnImplementationGuidesPresent.java | 6 +- .../starter/common/ElasticsearchConfig.java | 31 --- .../common/FhirServerConfigCommon.java | 30 ++- .../starter/common/FhirServerConfigDstu3.java | 2 +- .../starter/common/FhirServerConfigR4.java | 8 +- .../starter/common/FhirServerConfigR4B.java | 2 +- .../starter/common/FhirServerConfigR5.java | 2 +- .../common/OnPartitionModeEnabled.java | 6 +- .../jpa/starter/common/StarterJpaConfig.java | 61 ++++-- .../OnRemoteTerminologyPresent.java | 7 +- .../elastic/ElasticConfigCondition.java | 17 ++ .../elastic/ElasticsearchBootSvcImpl.java | 163 +++++++++++++++ .../jpa/starter/ig/IgConfigCondition.java | 14 -- ...mplementationGuideR4OperationProvider.java | 6 +- ...mplementationGuideR5OperationProvider.java | 6 +- .../jpa/starter/ips/IpsConfigCondition.java | 14 -- .../jpa/starter/ips/StarterIpsConfig.java | 6 +- .../fhir/jpa/starter/mcp/McpServerConfig.java | 27 ++- .../uhn/fhir/jpa/starter/mdm/MdmConfig.java | 4 +- .../jpa/starter/mdm/MdmConfigCondition.java | 13 -- .../jpa/starter/util/EnvironmentHelper.java | 154 +------------- src/main/resources/application.yaml | 190 +++++++++--------- .../fhir/jpa/starter/CdsHooksServletIT.java | 1 + .../jpa/starter/CustomInterceptorTest.java | 14 +- .../jpa/starter/ElasticsearchLastNR4IT.java | 38 ++-- .../jpa/starter/ExampleServerDbpmR5IT.java | 1 + .../jpa/starter/ExampleServerDstu2IT.java | 1 + .../jpa/starter/ExampleServerDstu3IT.java | 3 +- .../fhir/jpa/starter/ExampleServerR4BIT.java | 22 +- .../fhir/jpa/starter/ExampleServerR4IT.java | 4 + .../fhir/jpa/starter/ExampleServerR5IT.java | 1 + .../ca/uhn/fhir/jpa/starter/McpTests.java | 2 +- .../java/ca/uhn/fhir/jpa/starter/MdmTest.java | 6 +- .../jpa/starter/MultitenantServerR4IT.java | 1 + .../ParallelUpdatesVersionConflictTest.java | 3 +- ...application.yaml => application-test.yaml} | 79 -------- 39 files changed, 555 insertions(+), 534 deletions(-) create mode 100644 src/main/java/ca/uhn/fhir/jpa/provider/BaseJpaResourceProviderObservation.java delete mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/common/ElasticsearchConfig.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticConfigCondition.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java delete mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/ig/IgConfigCondition.java delete mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/ips/IpsConfigCondition.java delete mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/mdm/MdmConfigCondition.java rename src/test/resources/{application.yaml => application-test.yaml} (71%) diff --git a/pom.xml b/pom.xml index 3373897..ba38a3b 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ <properties> <java.version>17</java.version> - <hapi.fhir.jpa.server.starter.revision>2</hapi.fhir.jpa.server.starter.revision> + <hapi.fhir.jpa.server.starter.revision>3</hapi.fhir.jpa.server.starter.revision> <clinical-reasoning.version>3.26.0</clinical-reasoning.version> </properties> @@ -60,6 +60,16 @@ <dependencies> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-data-elasticsearch</artifactId> + <version>${spring_boot_version}</version> + </dependency> + <dependency> + <groupId>co.elastic.clients</groupId> + <artifactId>elasticsearch-java</artifactId> + </dependency> + <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> @@ -265,12 +275,7 @@ <artifactId>moment</artifactId> </dependency> - <!-- The following dependencies are only needed for automated unit tests, you do not neccesarily need them to run the example. --> - <dependency> - <groupId>co.elastic.clients</groupId> - <artifactId>elasticsearch-java</artifactId> - <scope>test</scope> - </dependency> + <dependency> <groupId>ca.uhn.hapi.fhir</groupId> @@ -386,17 +391,15 @@ <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp</artifactId> - <version>1.0.2</version> + <version>1.1.0-M1</version> </dependency> - <!-- - This will be included as well as using Spring Automatic Configuration - once spring-ai and io.modelcontextprotocol.sdk are on par - --> - <!--<dependency> + + <!--implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc:1.1.0-M1")--> + <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> - <version>1.0.2</version> - </dependency>--> + <version>1.1.0-M1</version> + </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> diff --git a/src/main/java/ca/uhn/fhir/jpa/provider/BaseJpaResourceProviderObservation.java b/src/main/java/ca/uhn/fhir/jpa/provider/BaseJpaResourceProviderObservation.java new file mode 100644 index 0000000..dd026cf --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/provider/BaseJpaResourceProviderObservation.java @@ -0,0 +1,108 @@ +/* + * #%L + * HAPI FHIR JPA Server + * %% + * Copyright (C) 2014 - 2025 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package ca.uhn.fhir.jpa.provider; + +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDaoObservation; +import ca.uhn.fhir.jpa.model.util.JpaConstants; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.model.api.annotation.Description; +import ca.uhn.fhir.model.valueset.BundleTypeEnum; +import ca.uhn.fhir.rest.annotation.Operation; +import ca.uhn.fhir.rest.annotation.OperationParam; +import ca.uhn.fhir.rest.annotation.RawParam; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.param.DateAndListParam; +import ca.uhn.fhir.rest.param.ReferenceAndListParam; +import ca.uhn.fhir.rest.param.TokenAndListParam; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IPrimitiveType; + +import java.util.List; +import java.util.Map; + +// Can be removed when https://github.com/hapifhir/hapi-fhir/issues/7255 is resolved +public abstract class BaseJpaResourceProviderObservation<T extends IBaseResource> extends BaseJpaResourceProvider<T> { + + /** + * Observation/$lastn + */ + @Operation(name = JpaConstants.OPERATION_LASTN, idempotent = true, bundleType = BundleTypeEnum.SEARCHSET) + public IBundleProvider observationLastN( + jakarta.servlet.http.HttpServletRequest theServletRequest, + jakarta.servlet.http.HttpServletResponse theServletResponse, + ca.uhn.fhir.rest.api.server.RequestDetails theRequestDetails, + @Description( + formalDefinition = + "Results from this method are returned across multiple pages. This parameter controls the size of those pages.") + @OperationParam(name = Constants.PARAM_COUNT, typeName = "unsignedInt") + IPrimitiveType<Integer> theCount, + @Description(shortDefinition = "The classification of the type of observation") + @OperationParam(name = "category") + TokenAndListParam theCategory, + @Description(shortDefinition = "The code of the observation type") @OperationParam(name = "code") + TokenAndListParam theCode, + @Description(shortDefinition = "The effective date of the observation") @OperationParam(name = "date") + DateAndListParam theDate, + @Description(shortDefinition = "The subject that the observation is about (if patient)") + @OperationParam(name = "patient") + ReferenceAndListParam thePatient, + @Description(shortDefinition = "The subject that the observation is about") + @OperationParam(name = "subject") + ReferenceAndListParam theSubject, + @Description(shortDefinition = "The maximum number of observations to return for each observation code") + @OperationParam(name = "max", typeName = "integer", min = 0, max = 1) + IPrimitiveType<Integer> theMax, + @RawParam Map<String, List<String>> theAdditionalRawParams) { + startRequest(theServletRequest); + try { + SearchParameterMap paramMap = new SearchParameterMap(); + paramMap.add(org.hl7.fhir.r4.model.Observation.SP_CATEGORY, theCategory); + paramMap.add(org.hl7.fhir.r4.model.Observation.SP_CODE, theCode); + paramMap.add(org.hl7.fhir.r4.model.Observation.SP_DATE, theDate); + if (thePatient != null) { + paramMap.add(org.hl7.fhir.r4.model.Observation.SP_PATIENT, thePatient); + } + if (theSubject != null) { + paramMap.add(org.hl7.fhir.r4.model.Observation.SP_SUBJECT, theSubject); + } + if (theMax != null) { + paramMap.setLastNMax(theMax.getValue()); + + /** + * The removal of the original raw parameter is required as every implementing class + * has the "Observation" resource class defined. For this resource, the max parameter + * is not supported and thus has to be removed before the use of "translateRawParameters". + */ + if (theAdditionalRawParams != null) theAdditionalRawParams.remove("max"); + } + if (theCount != null) { + paramMap.setCount(theCount.getValue()); + } + + getDao().translateRawParameters(theAdditionalRawParams, paramMap); + + return ((IFhirResourceDaoObservation<?>) getDao()) + .observationsLastN(paramMap, theRequestDetails, theServletResponse); + } finally { + endRequest(theServletRequest); + } + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/Application.java b/src/main/java/ca/uhn/fhir/jpa/starter/Application.java index c3033ad..4773cd1 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/Application.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/Application.java @@ -16,7 +16,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration; import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration; import org.springframework.boot.web.servlet.ServletComponentScan; import org.springframework.boot.web.servlet.ServletRegistrationBean; @@ -26,7 +25,7 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Import; @ServletComponentScan(basePackageClasses = {RestfulServer.class}) -@SpringBootApplication(exclude = {ElasticsearchRestClientAutoConfiguration.class, ThymeleafAutoConfiguration.class}) +@SpringBootApplication(exclude = {ThymeleafAutoConfiguration.class}) @Import({ StarterCrR4Config.class, StarterCrDstu3Config.class, diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/annotations/OnImplementationGuidesPresent.java b/src/main/java/ca/uhn/fhir/jpa/starter/annotations/OnImplementationGuidesPresent.java index c320801..88d290a 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/annotations/OnImplementationGuidesPresent.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/annotations/OnImplementationGuidesPresent.java @@ -1,7 +1,7 @@ package ca.uhn.fhir.jpa.starter.annotations; import ca.uhn.fhir.jpa.starter.AppProperties; -import org.springframework.boot.context.properties.bind.Binder; +import ca.uhn.fhir.jpa.starter.util.EnvironmentHelper; import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.core.type.AnnotatedTypeMetadata; @@ -10,9 +10,7 @@ public class OnImplementationGuidesPresent implements Condition { @Override public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata metadata) { - AppProperties config = Binder.get(conditionContext.getEnvironment()) - .bind("hapi.fhir", AppProperties.class) - .orElse(null); + AppProperties config = EnvironmentHelper.getConfiguration(conditionContext, "hapi.fhir", AppProperties.class); if (config == null) return false; if (config.getImplementationGuides() == null) return false; return !config.getImplementationGuides().isEmpty(); diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/ElasticsearchConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/ElasticsearchConfig.java deleted file mode 100644 index df8ce49..0000000 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/ElasticsearchConfig.java +++ /dev/null @@ -1,31 +0,0 @@ -package ca.uhn.fhir.jpa.starter.common; - -import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl; -import ca.uhn.fhir.jpa.starter.util.EnvironmentHelper; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.ConfigurableEnvironment; - -/** Shared configuration for Elasticsearch */ -@Configuration -public class ElasticsearchConfig { - private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ElasticsearchConfig.class); - - @Bean - public ElasticsearchSvcImpl elasticsearchSvc(ConfigurableEnvironment configurableEnvironment) { - if (EnvironmentHelper.isElasticsearchEnabled(configurableEnvironment)) { - String elasticsearchUrl = EnvironmentHelper.getElasticsearchServerUrl(configurableEnvironment); - if (elasticsearchUrl.startsWith("http")) { - elasticsearchUrl = elasticsearchUrl.substring(elasticsearchUrl.indexOf("://") + 3); - } - String elasticsearchProtocol = EnvironmentHelper.getElasticsearchServerProtocol(configurableEnvironment); - String elasticsearchUsername = EnvironmentHelper.getElasticsearchServerUsername(configurableEnvironment); - String elasticsearchPassword = EnvironmentHelper.getElasticsearchServerPassword(configurableEnvironment); - ourLog.info("Configuring elasticsearch {} {}", elasticsearchProtocol, elasticsearchUrl); - return new ElasticsearchSvcImpl( - elasticsearchProtocol, elasticsearchUrl, elasticsearchUsername, elasticsearchPassword); - } else { - return null; - } - } -} 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 924bc04..f28a2c6 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 @@ -9,6 +9,7 @@ import ca.uhn.fhir.jpa.model.config.PartitionSettings.CrossPartitionReferenceMod import ca.uhn.fhir.jpa.model.config.SubscriptionSettings; import ca.uhn.fhir.jpa.model.entity.StorageSettings; import ca.uhn.fhir.jpa.starter.AppProperties; +import ca.uhn.fhir.jpa.starter.elastic.ElasticsearchBootSvcImpl; import ca.uhn.fhir.jpa.starter.util.JpaHibernatePropertiesProvider; import ca.uhn.fhir.jpa.subscription.match.deliver.email.EmailSenderImpl; import ca.uhn.fhir.jpa.subscription.match.deliver.email.IEmailSender; @@ -19,10 +20,7 @@ import org.hl7.fhir.r4.model.Bundle.BundleType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.env.YamlPropertySourceLoader; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; -import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.*; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.transaction.annotation.EnableTransactionManagement; @@ -36,6 +34,7 @@ import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; */ @Configuration @EnableTransactionManagement +@Import(ElasticsearchBootSvcImpl.class) public class FhirServerConfigCommon { private static final Logger ourLog = LoggerFactory.getLogger(FhirServerConfigCommon.class); @@ -274,7 +273,15 @@ public class FhirServerConfigCommon { ourLog.debug("Server configured to Store Meta Source: {}", appProperties.getStore_meta_source_information()); jpaStorageSettings.setStoreMetaSourceInformation(appProperties.getStore_meta_source_information()); - storageSettings(appProperties, jpaStorageSettings); + jpaStorageSettings.setAllowContainsSearches(appProperties.getAllow_contains_searches()); + jpaStorageSettings.setAllowExternalReferences(appProperties.getAllow_external_references()); + jpaStorageSettings.setDefaultSearchParamsCanBeOverridden( + appProperties.getAllow_override_default_search_params()); + + jpaStorageSettings.setNormalizedQuantitySearchLevel(appProperties.getNormalized_quantity_search_level()); + + jpaStorageSettings.setIndexOnContainedResources(appProperties.getEnable_index_contained_resource()); + jpaStorageSettings.setIndexIdentifierOfType(appProperties.getEnable_index_of_type()); return jpaStorageSettings; } @@ -332,19 +339,6 @@ public class FhirServerConfigCommon { return new JpaHibernatePropertiesProvider(myEntityManagerFactory); } - protected StorageSettings storageSettings(AppProperties appProperties, JpaStorageSettings jpaStorageSettings) { - jpaStorageSettings.setAllowContainsSearches(appProperties.getAllow_contains_searches()); - jpaStorageSettings.setAllowExternalReferences(appProperties.getAllow_external_references()); - jpaStorageSettings.setDefaultSearchParamsCanBeOverridden( - appProperties.getAllow_override_default_search_params()); - - jpaStorageSettings.setNormalizedQuantitySearchLevel(appProperties.getNormalized_quantity_search_level()); - - jpaStorageSettings.setIndexOnContainedResources(appProperties.getEnable_index_contained_resource()); - jpaStorageSettings.setIndexIdentifierOfType(appProperties.getEnable_index_of_type()); - return jpaStorageSettings; - } - @Lazy @Bean public IBinaryStorageSvc binaryStorageSvc(AppProperties appProperties) { diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigDstu3.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigDstu3.java index a029d11..15e0272 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigDstu3.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigDstu3.java @@ -9,5 +9,5 @@ import org.springframework.context.annotation.Import; @Configuration @Conditional(OnDSTU3Condition.class) -@Import({JpaDstu3Config.class, StarterJpaConfig.class, StarterCrDstu3Config.class, ElasticsearchConfig.class}) +@Import({JpaDstu3Config.class, StarterJpaConfig.class, StarterCrDstu3Config.class}) public class FhirServerConfigDstu3 {} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR4.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR4.java index 55dce56..d749038 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR4.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR4.java @@ -10,11 +10,5 @@ import org.springframework.context.annotation.Import; @Configuration @Conditional(OnR4Condition.class) -@Import({ - JpaR4Config.class, - StarterJpaConfig.class, - StarterCrR4Config.class, - ElasticsearchConfig.class, - StarterIpsConfig.class -}) +@Import({JpaR4Config.class, StarterJpaConfig.class, StarterCrR4Config.class, StarterIpsConfig.class}) public class FhirServerConfigR4 {} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR4B.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR4B.java index ab267de..dcd5ab3 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR4B.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR4B.java @@ -9,5 +9,5 @@ import org.springframework.context.annotation.Import; @Configuration @Conditional(OnR4BCondition.class) -@Import({JpaR4BConfig.class, SubscriptionTopicConfig.class, StarterJpaConfig.class, ElasticsearchConfig.class}) +@Import({JpaR4BConfig.class, SubscriptionTopicConfig.class, StarterJpaConfig.class}) public class FhirServerConfigR4B {} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR5.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR5.java index 0aaa650..7a59285 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR5.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigR5.java @@ -9,5 +9,5 @@ import org.springframework.context.annotation.Import; @Configuration @Conditional(OnR5Condition.class) -@Import({StarterJpaConfig.class, JpaR5Config.class, SubscriptionTopicConfig.class, ElasticsearchConfig.class}) +@Import({StarterJpaConfig.class, JpaR5Config.class, SubscriptionTopicConfig.class}) public class FhirServerConfigR5 {} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/OnPartitionModeEnabled.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/OnPartitionModeEnabled.java index b695bab..aaf73d1 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/OnPartitionModeEnabled.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/OnPartitionModeEnabled.java @@ -1,7 +1,7 @@ package ca.uhn.fhir.jpa.starter.common; import ca.uhn.fhir.jpa.starter.AppProperties; -import org.springframework.boot.context.properties.bind.Binder; +import ca.uhn.fhir.jpa.starter.util.EnvironmentHelper; import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.core.type.AnnotatedTypeMetadata; @@ -9,9 +9,7 @@ import org.springframework.core.type.AnnotatedTypeMetadata; public class OnPartitionModeEnabled implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { - var appProperties = Binder.get(context.getEnvironment()) - .bind("hapi.fhir", AppProperties.class) - .orElse(null); + var appProperties = EnvironmentHelper.getConfiguration(context, "hapi.fhir", AppProperties.class); if (appProperties == null) return false; return appProperties.getPartitioning() != null; } 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 dc71733..1c1d2c7 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 @@ -52,7 +52,6 @@ import ca.uhn.fhir.jpa.starter.annotations.OnImplementationGuidesPresent; import ca.uhn.fhir.jpa.starter.common.validation.IRepositoryValidationInterceptorFactory; import ca.uhn.fhir.jpa.starter.ig.ExtendedPackageInstallationSpec; import ca.uhn.fhir.jpa.starter.ig.IImplementationGuideOperationProvider; -import ca.uhn.fhir.jpa.starter.util.EnvironmentHelper; import ca.uhn.fhir.jpa.subscription.util.SubscriptionDebugLogInterceptor; import ca.uhn.fhir.jpa.util.ResourceCountCache; import ca.uhn.fhir.mdm.provider.MdmProviderLoader; @@ -79,13 +78,17 @@ import ca.uhn.fhir.validation.IValidatorModule; import ca.uhn.fhir.validation.ResultSeverityEnum; import com.google.common.base.Strings; import jakarta.persistence.EntityManagerFactory; +import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy; +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.search.mapper.orm.cfg.HibernateOrmMapperSettings; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.NoSuchBeanDefinitionException; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; +import org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -93,13 +96,11 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Primary; -import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.http.HttpHeaders; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.web.cors.CorsConfiguration; -import java.io.IOException; import java.util.*; import javax.sql.DataSource; @@ -123,9 +124,6 @@ public class StarterJpaConfig { return new StaleSearchDeletingSvcImpl(); } - @Autowired - private ConfigurableEnvironment configurableEnvironment; - /** * Customize the default/max page sizes for search results. You can set these however * you want, although very large page sizes will require a lot of RAM. @@ -151,22 +149,46 @@ public class StarterJpaConfig { @Primary @Bean public LocalContainerEntityManagerFactoryBean entityManagerFactory( + JpaProperties theJpaProperties, DataSource myDataSource, ConfigurableListableBeanFactory myConfigurableListableBeanFactory, FhirContext theFhirContext, JpaStorageSettings theStorageSettings) { - LocalContainerEntityManagerFactoryBean retVal = HapiEntityManagerFactoryUtil.newEntityManagerFactory( - myConfigurableListableBeanFactory, theFhirContext, theStorageSettings); - retVal.setPersistenceUnitName("HAPI_PU"); + LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = + HapiEntityManagerFactoryUtil.newEntityManagerFactory( + myConfigurableListableBeanFactory, theFhirContext, theStorageSettings); - try { - retVal.setDataSource(myDataSource); - } catch (Exception e) { - throw new ConfigurationException("Could not set the data source due to a configuration issue", e); - } - retVal.setJpaProperties( - EnvironmentHelper.getHibernateProperties(configurableEnvironment, myConfigurableListableBeanFactory)); - return retVal; + // Spring Boot Autoconfiguration defaults + theJpaProperties + .getProperties() + .putIfAbsent(AvailableSettings.SCANNER, "org.hibernate.boot.archive.scan.internal.DisabledScanner"); + theJpaProperties + .getProperties() + .putIfAbsent(AvailableSettings.IMPLICIT_NAMING_STRATEGY, SpringImplicitNamingStrategy.class.getName()); + theJpaProperties + .getProperties() + .putIfAbsent( + AvailableSettings.PHYSICAL_NAMING_STRATEGY, + CamelCaseToUnderscoresNamingStrategy.class.getName()); + + // Hibernate Search defaults + theJpaProperties.getProperties().putIfAbsent(AvailableSettings.FORMAT_SQL, "false"); + theJpaProperties.getProperties().putIfAbsent(AvailableSettings.SHOW_SQL, "false"); + theJpaProperties.getProperties().putIfAbsent(AvailableSettings.HBM2DDL_AUTO, "update"); + theJpaProperties.getProperties().putIfAbsent(AvailableSettings.STATEMENT_BATCH_SIZE, "20"); + theJpaProperties.getProperties().putIfAbsent(AvailableSettings.USE_QUERY_CACHE, "false"); + theJpaProperties.getProperties().putIfAbsent(AvailableSettings.USE_SECOND_LEVEL_CACHE, "false"); + theJpaProperties.getProperties().putIfAbsent(AvailableSettings.USE_STRUCTURED_CACHE, "false"); + theJpaProperties.getProperties().putIfAbsent(AvailableSettings.USE_MINIMAL_PUTS, "false"); + + // Hibernate Search defaults + theJpaProperties.getProperties().putIfAbsent(HibernateOrmMapperSettings.ENABLED, "false"); + + entityManagerFactoryBean.setPersistenceUnitName("HAPI_PU"); + entityManagerFactoryBean.setJpaPropertyMap(theJpaProperties.getProperties()); + entityManagerFactoryBean.setDataSource(myDataSource); + + return entityManagerFactoryBean; } @Bean @@ -213,8 +235,7 @@ public class StarterJpaConfig { Batch2JobRegisterer batch2JobRegisterer, FhirContext fhirContext, TransactionProcessor transactionProcessor, - IHapiPackageCacheManager iHapiPackageCacheManager) - throws IOException { + IHapiPackageCacheManager iHapiPackageCacheManager) { batch2JobRegisterer.start(); diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/validation/OnRemoteTerminologyPresent.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/validation/OnRemoteTerminologyPresent.java index bd11463..8459323 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/validation/OnRemoteTerminologyPresent.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/validation/OnRemoteTerminologyPresent.java @@ -1,7 +1,7 @@ package ca.uhn.fhir.jpa.starter.common.validation; import ca.uhn.fhir.jpa.starter.AppProperties; -import org.springframework.boot.context.properties.bind.Binder; +import ca.uhn.fhir.jpa.starter.util.EnvironmentHelper; import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.core.type.AnnotatedTypeMetadata; @@ -10,9 +10,8 @@ public class OnRemoteTerminologyPresent implements Condition { @Override public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata metadata) { - AppProperties config = Binder.get(conditionContext.getEnvironment()) - .bind("hapi.fhir", AppProperties.class) - .orElse(null); + AppProperties config = EnvironmentHelper.getConfiguration(conditionContext, "hapi.fhir", AppProperties.class); + if (config == null) return false; if (config.getRemoteTerminologyServicesMap() == null) return false; return !config.getRemoteTerminologyServicesMap().isEmpty(); diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticConfigCondition.java b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticConfigCondition.java new file mode 100644 index 0000000..b98f194 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticConfigCondition.java @@ -0,0 +1,17 @@ +package ca.uhn.fhir.jpa.starter.elastic; + +import ca.uhn.fhir.jpa.starter.util.EnvironmentHelper; +import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchProperties; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +public class ElasticConfigCondition implements Condition { + + @Override + public boolean matches(ConditionContext theConditionContext, AnnotatedTypeMetadata theAnnotatedTypeMetadata) { + return EnvironmentHelper.getConfiguration( + theConditionContext, "spring.elasticsearch", ElasticsearchProperties.class) + != null; + } +} 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 new file mode 100644 index 0000000..82d8511 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java @@ -0,0 +1,163 @@ +package ca.uhn.fhir.jpa.starter.elastic; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.jpa.dao.TolerantJsonParser; +import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl; +import ca.uhn.fhir.jpa.search.lastn.IElasticsearchSvc; +import ca.uhn.fhir.jpa.search.lastn.json.ObservationJson; +import ca.uhn.fhir.parser.IParser; +import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.FieldValue; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.elasticsearch.indices.ExistsRequest; +import com.google.common.annotations.VisibleForTesting; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.springframework.context.annotation.Conditional; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Conditional(ElasticConfigCondition.class) +public class ElasticsearchBootSvcImpl implements IElasticsearchSvc { + + // Index Constants + public static final String OBSERVATION_INDEX = "observation_index"; + public static final String OBSERVATION_CODE_INDEX = "code_index"; + public static final String OBSERVATION_INDEX_SCHEMA_FILE = "ObservationIndexSchema.json"; + public static final String OBSERVATION_CODE_INDEX_SCHEMA_FILE = "ObservationCodeIndexSchema.json"; + + // Aggregation Constants + + // Observation index document element names + private static final String OBSERVATION_IDENTIFIER_FIELD_NAME = "identifier"; + + // Code index document element names + private static final String CODE_HASH = "codingcode_system_hash"; + private static final String CODE_TEXT = "text"; + + private static final String OBSERVATION_RESOURCE_NAME = "Observation"; + + private final ElasticsearchClient myRestHighLevelClient; + + private final FhirContext myContext; + + public ElasticsearchBootSvcImpl(ElasticsearchClient client, FhirContext fhirContext) { + + myContext = fhirContext; + myRestHighLevelClient = client; + + try { + createObservationIndexIfMissing(); + createObservationCodeIndexIfMissing(); + } catch (IOException theE) { + throw new RuntimeException(Msg.code(1175) + "Failed to create document index", theE); + } + } + + private String getIndexSchema(String theSchemaFileName) throws IOException { + InputStreamReader input = + new InputStreamReader(ElasticsearchSvcImpl.class.getResourceAsStream(theSchemaFileName)); + BufferedReader reader = new BufferedReader(input); + StringBuilder sb = new StringBuilder(); + String str; + while ((str = reader.readLine()) != null) { + sb.append(str); + } + + return sb.toString(); + } + + private void createObservationIndexIfMissing() throws IOException { + if (indexExists(OBSERVATION_INDEX)) { + return; + } + String observationMapping = getIndexSchema(OBSERVATION_INDEX_SCHEMA_FILE); + if (!createIndex(OBSERVATION_INDEX, observationMapping)) { + throw new RuntimeException(Msg.code(1176) + "Failed to create observation index"); + } + } + + private void createObservationCodeIndexIfMissing() throws IOException { + if (indexExists(OBSERVATION_CODE_INDEX)) { + return; + } + String observationCodeMapping = getIndexSchema(OBSERVATION_CODE_INDEX_SCHEMA_FILE); + if (!createIndex(OBSERVATION_CODE_INDEX, observationCodeMapping)) { + throw new RuntimeException(Msg.code(1177) + "Failed to create observation code index"); + } + } + + private boolean createIndex(String theIndexName, String theMapping) throws IOException { + return myRestHighLevelClient + .indices() + .create(cir -> cir.index(theIndexName).withJson(new StringReader(theMapping))) + .acknowledged(); + } + + private boolean indexExists(String theIndexName) throws IOException { + ExistsRequest request = new ExistsRequest.Builder().index(theIndexName).build(); + return myRestHighLevelClient.indices().exists(request).value(); + } + + @Override + public void close() { + // nothing + } + + @Override + public List<IBaseResource> getObservationResources(Collection<? extends IResourcePersistentId> thePids) { + SearchRequest searchRequest = buildObservationResourceSearchRequest(thePids); + try { + SearchResponse<ObservationJson> observationDocumentResponse = + myRestHighLevelClient.search(searchRequest, ObservationJson.class); + List<Hit<ObservationJson>> observationDocumentHits = + observationDocumentResponse.hits().hits(); + IParser parser = TolerantJsonParser.createWithLenientErrorHandling(myContext, null); + Class<? extends IBaseResource> resourceType = + myContext.getResourceDefinition(OBSERVATION_RESOURCE_NAME).getImplementingClass(); + /** + * @see ca.uhn.fhir.jpa.dao.BaseHapiFhirDao#toResource(Class, IBaseResourceEntity, Collection, boolean) for + * details about parsing raw json to BaseResource + */ + return observationDocumentHits.stream() + .map(Hit::source) + .map(observationJson -> parser.parseResource(resourceType, observationJson.getResource())) + .collect(Collectors.toList()); + } catch (IOException theE) { + throw new InvalidRequestException( + Msg.code(2003) + "Unable to execute observation document query for provided IDs " + thePids, theE); + } + } + + private SearchRequest buildObservationResourceSearchRequest(Collection<? extends IResourcePersistentId> thePids) { + List<FieldValue> values = thePids.stream() + .map(Object::toString) + .map(v -> FieldValue.of(v)) + .collect(Collectors.toList()); + + return SearchRequest.of(sr -> sr.index(OBSERVATION_INDEX) + .query(qb -> qb.bool(bb -> bb.must(bbm -> { + bbm.terms(terms -> + terms.field(OBSERVATION_IDENTIFIER_FIELD_NAME).terms(termsb -> termsb.value(values))); + return bbm; + }))) + .size(thePids.size())); + } + + @VisibleForTesting + public void refreshIndex(String theIndexName) throws IOException { + myRestHighLevelClient.indices().refresh(fn -> fn.index(theIndexName)); + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/ig/IgConfigCondition.java b/src/main/java/ca/uhn/fhir/jpa/starter/ig/IgConfigCondition.java deleted file mode 100644 index a93736b..0000000 --- a/src/main/java/ca/uhn/fhir/jpa/starter/ig/IgConfigCondition.java +++ /dev/null @@ -1,14 +0,0 @@ -package ca.uhn.fhir.jpa.starter.ig; - -import org.springframework.context.annotation.Condition; -import org.springframework.context.annotation.ConditionContext; -import org.springframework.core.type.AnnotatedTypeMetadata; - -public class IgConfigCondition implements Condition { - - @Override - public boolean matches(ConditionContext theConditionContext, AnnotatedTypeMetadata theAnnotatedTypeMetadata) { - String property = theConditionContext.getEnvironment().getProperty("hapi.fhir.ig_runtime_upload_enabled"); - return Boolean.parseBoolean(property); - } -} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/ig/ImplementationGuideR4OperationProvider.java b/src/main/java/ca/uhn/fhir/jpa/starter/ig/ImplementationGuideR4OperationProvider.java index 8ffb62c..2b2519e 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/ig/ImplementationGuideR4OperationProvider.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/ig/ImplementationGuideR4OperationProvider.java @@ -7,16 +7,18 @@ import ca.uhn.fhir.rest.annotation.Operation; import ca.uhn.fhir.rest.annotation.OperationParam; import org.hl7.fhir.r4.model.Base64BinaryType; import org.hl7.fhir.r4.model.Parameters; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Conditional; import org.springframework.stereotype.Service; import java.io.IOException; -@Conditional({OnR4Condition.class, IgConfigCondition.class}) +@Conditional({OnR4Condition.class}) +@ConditionalOnProperty(name = "hapi.fhir.ig_runtime_upload_enabled", havingValue = "true") @Service public class ImplementationGuideR4OperationProvider implements IImplementationGuideOperationProvider { - IPackageInstallerSvc packageInstallerSvc; + final IPackageInstallerSvc packageInstallerSvc; public ImplementationGuideR4OperationProvider(IPackageInstallerSvc packageInstallerSvc) { this.packageInstallerSvc = packageInstallerSvc; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/ig/ImplementationGuideR5OperationProvider.java b/src/main/java/ca/uhn/fhir/jpa/starter/ig/ImplementationGuideR5OperationProvider.java index b045a36..b845341 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/ig/ImplementationGuideR5OperationProvider.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/ig/ImplementationGuideR5OperationProvider.java @@ -7,16 +7,18 @@ import ca.uhn.fhir.rest.annotation.Operation; import ca.uhn.fhir.rest.annotation.OperationParam; import org.hl7.fhir.r5.model.Base64BinaryType; import org.hl7.fhir.r5.model.Parameters; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Conditional; import org.springframework.stereotype.Service; import java.io.IOException; -@Conditional({OnR5Condition.class, IgConfigCondition.class}) +@Conditional({OnR5Condition.class}) +@ConditionalOnProperty(name = "hapi.fhir.ig_runtime_upload_enabled", havingValue = "true") @Service public class ImplementationGuideR5OperationProvider implements IImplementationGuideOperationProvider { - IPackageInstallerSvc packageInstallerSvc; + final IPackageInstallerSvc packageInstallerSvc; public ImplementationGuideR5OperationProvider(IPackageInstallerSvc packageInstallerSvc) { this.packageInstallerSvc = packageInstallerSvc; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/ips/IpsConfigCondition.java b/src/main/java/ca/uhn/fhir/jpa/starter/ips/IpsConfigCondition.java deleted file mode 100644 index ca26e48..0000000 --- a/src/main/java/ca/uhn/fhir/jpa/starter/ips/IpsConfigCondition.java +++ /dev/null @@ -1,14 +0,0 @@ -package ca.uhn.fhir.jpa.starter.ips; - -import org.springframework.context.annotation.Condition; -import org.springframework.context.annotation.ConditionContext; -import org.springframework.core.type.AnnotatedTypeMetadata; - -public class IpsConfigCondition implements Condition { - - @Override - public boolean matches(ConditionContext theConditionContext, AnnotatedTypeMetadata theAnnotatedTypeMetadata) { - String property = theConditionContext.getEnvironment().getProperty("hapi.fhir.ips_enabled"); - return Boolean.parseBoolean(property); - } -} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/ips/StarterIpsConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/ips/StarterIpsConfig.java index 54e75f8..ffc1760 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/ips/StarterIpsConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/ips/StarterIpsConfig.java @@ -6,10 +6,12 @@ import ca.uhn.fhir.jpa.ips.generator.IIpsGeneratorSvc; import ca.uhn.fhir.jpa.ips.generator.IpsGeneratorSvcImpl; import ca.uhn.fhir.jpa.ips.jpa.DefaultJpaIpsGenerationStrategy; import ca.uhn.fhir.jpa.ips.provider.IpsOperationProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; -@Conditional(IpsConfigCondition.class) +@Configuration +@ConditionalOnProperty(name = "hapi.fhir.ips_enabled", havingValue = "true") public class StarterIpsConfig { @Bean IIpsGenerationStrategy ipsGenerationStrategy() { diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java index 9dec687..1254f43 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java @@ -8,14 +8,14 @@ import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.hapi.fhir.cdshooks.api.ICdsServiceRegistry; import ca.uhn.hapi.fhir.cdshooks.module.CdsHooksObjectMapperFactory; import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; +import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerStreamableHttpProperties; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import java.util.List; @@ -30,19 +30,17 @@ import java.util.List; prefix = "spring.ai.mcp.server", name = {"enabled"}, havingValue = "true") +@Import(McpServerStreamableHttpProperties.class) public class McpServerConfig { private static final String SSE_ENDPOINT = "/sse"; private static final String SSE_MESSAGE_ENDPOINT = "/mcp/message"; @Bean - public McpSyncServer syncServer( - List<McpBridge> mcpBridges, McpStreamableServerTransportProvider transportProvider) { - return McpServer.sync(transportProvider) - .tools(mcpBridges.stream() - .flatMap(bridge -> bridge.generateTools().stream()) - .toList()) - .build(); + public List<McpServerFeatures.SyncToolSpecification> syncServer(List<McpBridge> mcpBridges) { + return mcpBridges.stream() + .flatMap(bridge -> bridge.generateTools().stream()) + .toList(); } @Bean @@ -63,11 +61,11 @@ public class McpServerConfig { @Bean public HttpServletStreamableServerTransportProvider servletSseServerTransportProvider( - /*McpServerProperties properties*/ ) { + McpServerStreamableHttpProperties properties) { return HttpServletStreamableServerTransportProvider.builder() .disallowDelete(false) - .mcpEndpoint(SSE_MESSAGE_ENDPOINT) + .mcpEndpoint(properties.getMcpEndpoint()) .objectMapper(new ObjectMapper()) // .contextExtractor((serverRequest, context) -> context) .build(); @@ -75,7 +73,8 @@ public class McpServerConfig { @Bean public ServletRegistrationBean customServletBean( - HttpServletStreamableServerTransportProvider transportProvider /*, McpServerProperties properties*/) { - return new ServletRegistrationBean<>(transportProvider, SSE_MESSAGE_ENDPOINT, SSE_ENDPOINT); + HttpServletStreamableServerTransportProvider transportProvider, + McpServerStreamableHttpProperties properties) { + return new ServletRegistrationBean<>(transportProvider, properties.getMcpEndpoint(), SSE_ENDPOINT); } } diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mdm/MdmConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/mdm/MdmConfig.java index 2e8a1f1..604b97f 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/mdm/MdmConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mdm/MdmConfig.java @@ -9,8 +9,8 @@ import ca.uhn.fhir.mdm.rules.config.MdmRuleValidator; import ca.uhn.fhir.mdm.rules.config.MdmSettings; import org.apache.commons.io.IOUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.core.io.DefaultResourceLoader; @@ -20,7 +20,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; @Configuration -@Conditional(MdmConfigCondition.class) +@ConditionalOnProperty(prefix = "hapi.fhir", name = "mdm_enabled") @Import({MdmConsumerConfig.class, MdmSubmitterConfig.class, NicknameServiceConfig.class}) public class MdmConfig { diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mdm/MdmConfigCondition.java b/src/main/java/ca/uhn/fhir/jpa/starter/mdm/MdmConfigCondition.java deleted file mode 100644 index 7c3bf5b..0000000 --- a/src/main/java/ca/uhn/fhir/jpa/starter/mdm/MdmConfigCondition.java +++ /dev/null @@ -1,13 +0,0 @@ -package ca.uhn.fhir.jpa.starter.mdm; - -import org.springframework.context.annotation.Condition; -import org.springframework.context.annotation.ConditionContext; -import org.springframework.core.type.AnnotatedTypeMetadata; - -public class MdmConfigCondition implements Condition { - @Override - public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata metadata) { - String property = conditionContext.getEnvironment().getProperty("hapi.fhir.mdm_enabled"); - return Boolean.parseBoolean(property); - } -} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/util/EnvironmentHelper.java b/src/main/java/ca/uhn/fhir/jpa/starter/util/EnvironmentHelper.java index 5d983ec..6499b51 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/util/EnvironmentHelper.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/util/EnvironmentHelper.java @@ -1,22 +1,7 @@ package ca.uhn.fhir.jpa.starter.util; -import ca.uhn.fhir.jpa.config.HapiFhirLocalContainerEntityManagerFactoryBean; -import ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers; -import ca.uhn.fhir.jpa.search.elastic.ElasticsearchHibernatePropertiesBuilder; -import org.apache.lucene.util.Version; -import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy; -import org.hibernate.cfg.AvailableSettings; -import org.hibernate.search.backend.elasticsearch.cfg.ElasticsearchBackendSettings; -import org.hibernate.search.backend.elasticsearch.index.IndexStatus; -import org.hibernate.search.backend.lucene.cfg.LuceneBackendSettings; -import org.hibernate.search.backend.lucene.cfg.LuceneIndexSettings; -import org.hibernate.search.backend.lucene.lowlevel.directory.impl.LocalFileSystemDirectoryProvider; -import org.hibernate.search.engine.cfg.BackendSettings; -import org.hibernate.search.mapper.orm.automaticindexing.session.AutomaticIndexingSynchronizationStrategyNames; -import org.hibernate.search.mapper.orm.cfg.HibernateOrmMapperSettings; -import org.hibernate.search.mapper.orm.schema.management.SchemaManagementStrategyName; -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.ConditionContext; import org.springframework.core.env.CompositePropertySource; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.EnumerablePropertySource; @@ -25,142 +10,11 @@ import org.springframework.core.env.PropertySource; import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import java.util.Properties; - -import static java.util.Objects.requireNonNullElse; public class EnvironmentHelper { - public static Properties getHibernateProperties( - ConfigurableEnvironment environment, ConfigurableListableBeanFactory myConfigurableListableBeanFactory) { - Properties properties = new Properties(); - Map<String, Object> jpaProps = getPropertiesStartingWith(environment, "spring.jpa.properties"); - for (Map.Entry<String, Object> entry : jpaProps.entrySet()) { - String strippedKey = entry.getKey().replace("spring.jpa.properties.", ""); - properties.put(strippedKey, entry.getValue().toString()); - } - - // also check for JPA properties set as environment variables, this is slightly hacky and doesn't cover all - // the naming conventions Springboot allows - // but there doesn't seem to be a better/deterministic way to get these properties when they are set as ENV - // variables and this at least provides - // a way to set them (in a docker container, for instance) - Map<String, Object> jpaPropsEnv = getPropertiesStartingWith(environment, "SPRING_JPA_PROPERTIES"); - for (Map.Entry<String, Object> entry : jpaPropsEnv.entrySet()) { - String strippedKey = entry.getKey().replace("SPRING_JPA_PROPERTIES_", ""); - strippedKey = strippedKey.replaceAll("_", "."); - strippedKey = strippedKey.toLowerCase(); - properties.put(strippedKey, entry.getValue().toString()); - } - - // Spring Boot Autoconfiguration defaults - properties.putIfAbsent(AvailableSettings.SCANNER, "org.hibernate.boot.archive.scan.internal.DisabledScanner"); - properties.putIfAbsent( - AvailableSettings.IMPLICIT_NAMING_STRATEGY, SpringImplicitNamingStrategy.class.getName()); - properties.putIfAbsent( - AvailableSettings.PHYSICAL_NAMING_STRATEGY, CamelCaseToUnderscoresNamingStrategy.class.getName()); - // TODO The bean factory should be added as parameter but that requires that it can be injected from the - // entityManagerFactory bean from xBaseConfig - // properties.putIfAbsent(AvailableSettings.BEAN_CONTAINER, new SpringBeanContainer(beanFactory)); - - // hapi-fhir-jpaserver-base "sensible defaults" - Map<String, Object> hapiJpaPropertyMap = new HapiFhirLocalContainerEntityManagerFactoryBean( - myConfigurableListableBeanFactory) - .getJpaPropertyMap(); - hapiJpaPropertyMap.forEach(properties::putIfAbsent); - - // hapi-fhir-jpaserver-starter defaults - properties.putIfAbsent(AvailableSettings.FORMAT_SQL, false); - properties.putIfAbsent(AvailableSettings.SHOW_SQL, false); - properties.putIfAbsent(AvailableSettings.HBM2DDL_AUTO, "update"); - properties.putIfAbsent(AvailableSettings.STATEMENT_BATCH_SIZE, 20); - properties.putIfAbsent(AvailableSettings.USE_QUERY_CACHE, false); - properties.putIfAbsent(AvailableSettings.USE_SECOND_LEVEL_CACHE, false); - properties.putIfAbsent(AvailableSettings.USE_STRUCTURED_CACHE, false); - properties.putIfAbsent(AvailableSettings.USE_MINIMAL_PUTS, false); - - // Hibernate Search defaults - properties.putIfAbsent(HibernateOrmMapperSettings.ENABLED, false); - if (Boolean.parseBoolean(String.valueOf(properties.get(HibernateOrmMapperSettings.ENABLED)))) { - if (isElasticsearchEnabled(environment)) { - properties.putIfAbsent( - BackendSettings.backendKey(BackendSettings.TYPE), ElasticsearchBackendSettings.TYPE_NAME); - } else { - properties.putIfAbsent( - BackendSettings.backendKey(BackendSettings.TYPE), LuceneBackendSettings.TYPE_NAME); - } - - if (properties - .get(BackendSettings.backendKey(BackendSettings.TYPE)) - .equals(LuceneBackendSettings.TYPE_NAME)) { - properties.putIfAbsent( - BackendSettings.backendKey(LuceneIndexSettings.DIRECTORY_TYPE), - LocalFileSystemDirectoryProvider.NAME); - properties.putIfAbsent( - BackendSettings.backendKey(LuceneIndexSettings.DIRECTORY_ROOT), "target/lucenefiles"); - properties.putIfAbsent( - BackendSettings.backendKey(LuceneBackendSettings.ANALYSIS_CONFIGURER), - HapiHSearchAnalysisConfigurers.HapiLuceneAnalysisConfigurer.class.getName()); - properties.putIfAbsent( - BackendSettings.backendKey(LuceneBackendSettings.LUCENE_VERSION), Version.LATEST); - - } else if (properties - .get(BackendSettings.backendKey(BackendSettings.TYPE)) - .equals(ElasticsearchBackendSettings.TYPE_NAME)) { - ElasticsearchHibernatePropertiesBuilder builder = new ElasticsearchHibernatePropertiesBuilder(); - IndexStatus requiredIndexStatus = - environment.getProperty("elasticsearch.required_index_status", IndexStatus.class); - builder.setRequiredIndexStatus(requireNonNullElse(requiredIndexStatus, IndexStatus.YELLOW)); - builder.setHosts(getElasticsearchServerUrl(environment)); - builder.setUsername(getElasticsearchServerUsername(environment)); - builder.setPassword(getElasticsearchServerPassword(environment)); - builder.setProtocol(getElasticsearchServerProtocol(environment)); - SchemaManagementStrategyName indexSchemaManagementStrategy = environment.getProperty( - "elasticsearch.schema_management_strategy", SchemaManagementStrategyName.class); - builder.setIndexSchemaManagementStrategy( - requireNonNullElse(indexSchemaManagementStrategy, SchemaManagementStrategyName.CREATE)); - Boolean refreshAfterWrite = - environment.getProperty("elasticsearch.debug.refresh_after_write", Boolean.class); - if (refreshAfterWrite == null || !refreshAfterWrite) { - builder.setDebugIndexSyncStrategy(AutomaticIndexingSynchronizationStrategyNames.ASYNC); - } else { - builder.setDebugIndexSyncStrategy(AutomaticIndexingSynchronizationStrategyNames.READ_SYNC); - } - builder.setDebugPrettyPrintJsonLog(requireNonNullElse( - environment.getProperty("elasticsearch.debug.pretty_print_json_log", Boolean.class), false)); - builder.apply(properties); - - } else { - throw new UnsupportedOperationException("Unsupported Hibernate Search backend: " - + properties.get(BackendSettings.backendKey(BackendSettings.TYPE))); - } - } - - return properties; - } - - public static String getElasticsearchServerUrl(ConfigurableEnvironment environment) { - return environment.getProperty("elasticsearch.rest_url", String.class); - } - - public static String getElasticsearchServerProtocol(ConfigurableEnvironment environment) { - return environment.getProperty("elasticsearch.protocol", String.class, "http"); - } - - public static String getElasticsearchServerUsername(ConfigurableEnvironment environment) { - return environment.getProperty("elasticsearch.username"); - } - - public static String getElasticsearchServerPassword(ConfigurableEnvironment environment) { - return environment.getProperty("elasticsearch.password"); - } - - public static Boolean isElasticsearchEnabled(ConfigurableEnvironment environment) { - if (environment.getProperty("elasticsearch.enabled", Boolean.class) != null) { - return environment.getProperty("elasticsearch.enabled", Boolean.class); - } else { - return false; - } + public static <T> T getConfiguration(ConditionContext context, String path, Class<T> clazz) { + return Binder.get(context.getEnvironment()).bind(path, clazz).orElse(null); } public static Map<String, Object> getPropertiesStartingWith(ConfigurableEnvironment aEnv, String aKeyPrefix) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 662608a..3a9ac8a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,12 +9,16 @@ server: #Adds the option to go to e.g. http://localhost:8080/actuator/health for seeing the running configuration #see https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints management: + health: + elasticsearch: + enabled: false #The following configuration will enable the actuator endpoints at /actuator/health, /actuator/info, /actuator/prometheus, /actuator/metrics. For security purposes, only /actuator/health is enabled by default. endpoints: enabled-by-default: false web: exposure: - include: 'health' # or e.g. 'info,health,prometheus,metrics' or '*' for all + # expose only health (default) — change to [health,info,prometheus,metrics] if you want them reachable + include: health endpoint: info: enabled: true @@ -63,21 +67,13 @@ spring: mcp: server: - # Will be enabled once spring-ai-starter-mcp-server is added as dependency -# name: FHIR MCP Server -# version: 1.0.0 -# type: SYNC -# instructions: "This server provides access to a FHIR RESTful API. You can use it to query FHIR resources, perform operations, and retrieve data in a structured format." -# sse-message-endpoint: /mcp/message -# capabilities: -# tool: true -# resource: true -# prompt: true -# completion: true -# stdio: false + name: FHIR MCP Server + version: 1.0.0 + instructions: "This server provides access to a FHIR RESTful API. You can use it to query FHIR resources, perform operations, and retrieve data in a structured format." enabled: true + streamable-http: + mcp-endpoint: /mcp/messages - #endpoint: /mcp #schema: # fhir-enabled: true @@ -93,51 +89,74 @@ spring: # {{schema}} #base-url: /api/v1 + + autoconfigure: + # This exclude is only needed for setups not using Elasticsearch where the elasticsearch sniff is not needed. + exclude: org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration main: allow-bean-definition-overriding: false allow-circular-references: true flyway: enabled: false - baselineOnMigrate: true + baseline-on-migrate: true fail-on-missing-locations: false datasource: #url: 'jdbc:h2:file:./target/database/h2' url: jdbc:h2:mem:test_mem username: sa password: null - driverClassName: org.h2.Driver - max-active: 15 + driver-class-name: org.h2.Driver # database connection pool size hikari: maximum-pool-size: 10 + # elasticsearch: + # uris: http://localhost:9200 + # username: elastic + # password: changeme jpa: properties: - hibernate.format_sql: false - hibernate.show_sql: false + hibernate: + hbm2ddl: + auto: update + jdbc: + batch_size: 20 + cache: + use_query_cache: false + use_second_level_cache: false + use_structured_entries: false + use_minimal_puts: false + format_sql: false + show_sql: false + #If using H2, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect + #If using postgres, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect + #dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect + dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect + search: + enabled: true + schema_management: + strategy: create + ### lucene parameters + backend: + type: lucene + directory: + type: local-filesystem + root: target/lucenefiles + analysis: + configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer - #Hibernate dialect is automatically detected except Postgres and H2. - #If using H2, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect - #If using postgres, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect - hibernate.dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect - # hibernate.hbm2ddl.auto: update - # hibernate.jdbc.batch_size: 20 - # hibernate.cache.use_query_cache: false - # hibernate.cache.use_second_level_cache: false - # hibernate.cache.use_structured_entries: false - # hibernate.cache.use_minimal_puts: false + ### elastic parameters ===> see also elasticsearch section below <=== +# backend: +# type: elasticsearch +# discovery: true +# analysis: +# configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer +# hosts: localhost:9200 +# protocol: http +# username: elastic +# password: changeme +# refresh_after_write: true - ### These settings will enable fulltext search with lucene or elastic - hibernate.search.enabled: false - ### lucene parameters -# hibernate.search.backend.type: lucene -# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer -# hibernate.search.backend.directory.type: local-filesystem -# hibernate.search.backend.directory.root: target/lucenefiles -# hibernate.search.backend.lucene_version: lucene_current - ### elastic parameters ===> see also elasticsearch section below <=== -# hibernate.search.backend.type: elasticsearch -# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer hapi: fhir: ### This flag when enabled to true, will avail evaluate measure operations from CR Module. @@ -201,42 +220,42 @@ hapi: fhir_version: R4 ### Flag is false by default. This flag enables runtime installation of IG's. ig_runtime_upload_enabled: false - ### This flag when enabled to true, will avail evaluate measure operations from CR Module. + ### This flag when enabled to true, will avail evaluate measure operations from CR Module. - ### enable to use the ApacheProxyAddressStrategy which uses X-Forwarded-* headers - ### to determine the FHIR server address - # use_apache_address_strategy: false - ### forces the use of the https:// protocol for the returned server address. - ### alternatively, it may be set using the X-Forwarded-Proto header. - # use_apache_address_strategy_https: false - ### enables the server to overwrite defaults on HTML, css, etc. under the url pattern of eg. /content/custom ** - ### Folder with custom content MUST be named custom. If omitted then default content applies - #custom_content_path: ./custom - ### enables the server host custom content. If e.g. the value ./configs/app is supplied then the content - ### will be served under /web/app - #app_content_path: ./configs/app - ### enable to set the Server URL - # server_address: http://hapi.fhir.org/baseR4 - # defer_indexing_for_codesystems_of_size: 101 - ### Flag is true by default. This flag filters resources during package installation, allowing only those resources with a valid status (e.g. active) to be installed. - # validate_resource_status_for_package_upload: false - # install_transitive_ig_dependencies: true - #implementationguides: - ### example from registry (packages.fhir.org) - # swiss: - # name: swiss.mednet.fhir - # version: 0.8.0 - # reloadExisting: false - # installMode: STORE_AND_INSTALL - # example not from registry - # ips_1_0_0: - # packageUrl: https://costateixeira.github.io/smart-ips-pilgrimage-fulltest/package.tgz - # name: smart.who.int.ips-pilgrimage-test - # version: 0.1.0 + ### enable to use the ApacheProxyAddressStrategy which uses X-Forwarded-* headers + ### to determine the FHIR server address + # use_apache_address_strategy: false + ### forces the use of the https:// protocol for the returned server address. + ### alternatively, it may be set using the X-Forwarded-Proto header. + # use_apache_address_strategy_https: false + ### enables the server to overwrite defaults on HTML, css, etc. under the url pattern of eg. /content/custom ** + ### Folder with custom content MUST be named custom. If omitted then default content applies + #custom_content_path: ./custom + ### enables the server host custom content. If e.g. the value ./configs/app is supplied then the content + ### will be served under /web/app + #app_content_path: ./configs/app + ### enable to set the Server URL + # server_address: http://hapi.fhir.org/baseR4 + # defer_indexing_for_codesystems_of_size: 101 + ### Flag is true by default. This flag filters resources during package installation, allowing only those resources with a valid status (e.g. active) to be installed. + # validate_resource_status_for_package_upload: false + # install_transitive_ig_dependencies: true + #implementationguides: + ### example from registry (packages.fhir.org) + # swiss: + # name: swiss.mednet.fhir + # version: 0.8.0 + # reloadExisting: false + # installMode: STORE_AND_INSTALL + # example not from registry + # ips_1_0_0: + # packageUrl: https://costateixeira.github.io/smart-ips-pilgrimage-fulltest/package.tgz + # name: smart.who.int.ips-pilgrimage-test + # version: 0.1.0 # installMode: STORE_AND_INSTALL # additionalResourceFolders: # - example - # - example2 + # - example2 # supported_resource_types: # - Patient # - Observation @@ -309,21 +328,21 @@ hapi: - http://loinc.org/* - https://loinc.org/* - ### Uncomment the following section, and any sub-properties you need in order to enable - ### partitioning support on this server. - #partitioning: - # allow_references_across_partitions: false - # partitioning_include_in_search_hashes: false - # default_partition_id: 0 + ### Uncomment the following section, and any sub-properties you need in order to enable + ### partitioning support on this server. + #partitioning: + # allow_references_across_partitions: false + # partitioning_include_in_search_hashes: false + # default_partition_id: 0 ### Enable the following setting to enable Database Partitioning Mode ### See: https://hapifhir.io/hapi-fhir/docs/server_jpa_partitioning/db_partition_mode.html - # database_partition_mode_enabled: true + # database_partition_mode_enabled: true ### Partition Style: Partitioning requires a partition interceptor which helps the server ### select which partition(s) should be accessed for a given request. You can supply your ### own interceptor (see https://hapifhir.io/hapi-fhir/docs/server_jpa_partitioning/partitioning.html#partition-interceptors ) ### but the following setting can also be used to use a built-in form. ### Patient ID Partitioning Mode uses the patient/subject ID to determine the partition - # patient_id_partitioning_mode: true + # patient_id_partitioning_mode: true ### Request tenant mode can be used for a multi-tenancy setup where the request path is ### expected to have an additional path element, e.g. GET http://example.com/fhir/TENANT-ID/Patient/A # request_tenant_partitioning_mode: false @@ -432,14 +451,3 @@ hapi: ### 1: NORMALIZED_QUANTITY_STORAGE_SUPPORTED ### 2: NORMALIZED_QUANTITY_SEARCH_SUPPORTED # normalized_quantity_search_level: 2 -#elasticsearch: -# debug: -# pretty_print_json_log: false -# refresh_after_write: false -# enabled: false -# password: SomePassword -# required_index_status: YELLOW -# rest_url: 'localhost:9200' -# protocol: 'http' -# schema_management_strategy: CREATE -# username: SomeUsername diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java b/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java index 6a0f889..1e77b3a 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java @@ -46,6 +46,7 @@ import static org.junit.jupiter.api.Assertions.fail; }, properties = { "spring.profiles.include=storageSettingsTest", "spring.datasource.url=jdbc:h2:mem:dbr4", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", "hapi.fhir.enable_repository_validating_interceptor=true", "hapi.fhir.fhir_version=r4", "hapi.fhir.cr.enabled=true", diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/CustomInterceptorTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/CustomInterceptorTest.java index daf6438..be0415c 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/CustomInterceptorTest.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/CustomInterceptorTest.java @@ -14,14 +14,14 @@ import ca.uhn.fhir.rest.client.api.IGenericClient; import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { - "hapi.fhir.custom-bean-packages=some.custom.pkg1", - "hapi.fhir.custom-interceptor-classes=some.custom.pkg1.CustomInterceptorBean,some.custom.pkg1.CustomInterceptorPojo", - "spring.datasource.url=jdbc:h2:mem:dbr4", - "hapi.fhir.cr_enabled=false", - // "hapi.fhir.enable_repository_validating_interceptor=true", - "hapi.fhir.fhir_version=r4" + "hapi.fhir.custom-bean-packages=some.custom.pkg1", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", + "hapi.fhir.custom-interceptor-classes=some.custom.pkg1.CustomInterceptorBean,some.custom.pkg1.CustomInterceptorPojo", + "spring.datasource.url=jdbc:h2:mem:dbr4", + "hapi.fhir.cr_enabled=false", + // "hapi.fhir.enable_repository_validating_interceptor=true", + "hapi.fhir.fhir_version=r4" }) - class CustomInterceptorTest { @LocalServerPort 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 30b4511..c834fc5 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java @@ -3,8 +3,8 @@ 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.ElasticsearchRestClientFactory; import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl; +import ca.uhn.fhir.jpa.starter.elastic.ElasticsearchBootSvcImpl; import ca.uhn.fhir.jpa.test.config.TestElasticsearchContainerHelper; import ca.uhn.fhir.rest.client.api.IGenericClient; import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; @@ -14,8 +14,6 @@ import java.io.IOException; import java.util.Date; import java.util.GregorianCalendar; -import co.elastic.clients.elasticsearch.ElasticsearchClient; -import co.elastic.clients.elasticsearch.indices.IndexSettings; import jakarta.annotation.PreDestroy; import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.r4.model.Bundle; @@ -27,6 +25,8 @@ import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.StringType; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -42,6 +42,7 @@ import org.testcontainers.junit.jupiter.Testcontainers; @ExtendWith(SpringExtension.class) @Testcontainers +@Disabled @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { "spring.datasource.url=jdbc:h2:mem:dbr4", @@ -50,19 +51,19 @@ import org.testcontainers.junit.jupiter.Testcontainers; "hapi.fhir.store_resource_in_lucene_index_enabled=true", "hapi.fhir.advanced_lucene_indexing=true", "hapi.fhir.search_index_full_text_enabled=true", - - "elasticsearch.enabled=true", "hapi.fhir.cr_enabled=false", // Because the port is set randomly, we will set the rest_url using the Initializer. // "elasticsearch.rest_url='http://localhost:9200'", - "elasticsearch.username=SomeUsername", - "elasticsearch.password=SomePassword", - "elasticsearch.debug.refresh_after_write=true", - "elasticsearch.protocol=http", + + "spring.elasticsearch.uris=http://localhost:9200", + "spring.elasticsearch.username=elastic", + "spring.elasticsearch.password=changeme", "spring.main.allow-bean-definition-overriding=true", "spring.jpa.properties.hibernate.search.enabled=true", "spring.jpa.properties.hibernate.search.backend.type=elasticsearch", - "spring.jpa.properties.hibernate.search.backend.analysis.configurer=ca.uhn.fhir.jpa.search.elastic.HapiElasticsearchAnalysisConfigurer" + "spring.jpa.properties.hibernate.search.backend.hosts=localhost:9200", + "spring.jpa.properties.hibernate.search.backend.protocol=http", + "spring.jpa.properties.hibernate.search.backend.analysis.configurer=ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticsearchAnalysisConfigurer" }) @ContextConfiguration(initializers = ElasticsearchLastNR4IT.Initializer.class) class ElasticsearchLastNR4IT { @@ -73,26 +74,26 @@ class ElasticsearchLastNR4IT { public static ElasticsearchContainer embeddedElastic = TestElasticsearchContainerHelper.getEmbeddedElasticSearch(); @Autowired - private ElasticsearchSvcImpl myElasticsearchSvc; + private ElasticsearchBootSvcImpl myElasticsearchSvc; @BeforeAll public static void beforeClass() throws IOException { //Given - ElasticsearchClient elasticsearchHighLevelRestClient = ElasticsearchRestClientFactory.createElasticsearchHighLevelRestClient( - "http", embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200), "", ""); + // ElasticsearchClient elasticsearchHighLevelRestClient = ElasticsearchRestClientFactory.createElasticsearchHighLevelRestClient( +// "http", embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200), "", ""); /* As of 2023-08-10, HAPI FHIR sets SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS to 50000 which is in excess of elastic's default max_result_window. If MAX_SUBSCRIPTION_RESULTS is changed to a value <= 10000, the following will no longer be necessary. - dotasek */ - elasticsearchHighLevelRestClient.indices().putTemplate(t->{ + /* elasticsearchHighLevelRestClient.indices().putTemplate(t->{ t.name("hapi_fhir_template"); t.indexPatterns("*"); t.settings(new IndexSettings.Builder().maxResultWindow(50000).build()); return t; }); - +*/ } @PreDestroy @@ -103,7 +104,7 @@ class ElasticsearchLastNR4IT { @LocalServerPort private int port; - //@Test + @Test void testLastN() throws IOException, InterruptedException { Thread.sleep(2000); @@ -125,6 +126,7 @@ class ElasticsearchLastNR4IT { IIdType obsId = ourClient.create().resource(obs).execute().getId().toUnqualifiedVersionless(); myElasticsearchSvc.refreshIndex(ElasticsearchSvcImpl.OBSERVATION_INDEX); + Thread.sleep(2000); Parameters output = ourClient.operation().onType(Observation.class).named("lastn") .withParameter(Parameters.class, "max", new IntegerType(1)) @@ -154,8 +156,10 @@ class ElasticsearchLastNR4IT { public void initialize( ConfigurableApplicationContext configurableApplicationContext) { // Since the port is dynamically generated, replace the URL with one that has the correct port - TestPropertyValues.of("elasticsearch.rest_url=" + embeddedElastic.getHost() +":" + embeddedElastic.getMappedPort(9200)) + TestPropertyValues.of("spring.elasticsearch.uris=" + embeddedElastic.getHost() +":" + embeddedElastic.getMappedPort(9200)) .applyTo(configurableApplicationContext.getEnvironment()); + TestPropertyValues.of("spring.jpa.properties.hibernate.search.backend.hosts=" + embeddedElastic.getHost() +":" + embeddedElastic.getMappedPort(9200)) + .applyTo(configurableApplicationContext.getEnvironment()); } } diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDbpmR5IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDbpmR5IT.java index 0a6a103..f6ce911 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDbpmR5IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDbpmR5IT.java @@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { "spring.datasource.url=jdbc:h2:mem:dbr5_dbpm", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", "hapi.fhir.fhir_version=r5", "hapi.fhir.partitioning.database_partition_mode_enabled=true", "hapi.fhir.partitioning.patient_id_partitioning_mode=true" diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDstu2IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDstu2IT.java index e8fd9ad..97c5922 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDstu2IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDstu2IT.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; "hapi.fhir.fhir_version=dstu2", "spring.datasource.url=jdbc:h2:mem:dbr2", "hapi.fhir.cr_enabled=false", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap" }) class ExampleServerDstu2IT { diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDstu3IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDstu3IT.java index f370e8f..930001e 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDstu3IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerDstu3IT.java @@ -49,7 +49,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; "hapi.fhir.subscription.websocket_enabled=true", "hapi.fhir.allow_external_references=true", "hapi.fhir.allow_placeholder_references=true", - "spring.main.allow-bean-definition-overriding=true" + "spring.main.allow-bean-definition-overriding=true", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap" }) class ExampleServerDstu3IT implements IServerSupport { diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4BIT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4BIT.java index 5a1d2f1..e9e90bb 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4BIT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4BIT.java @@ -17,16 +17,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { - "spring.datasource.url=jdbc:h2:mem:dbr4b", - "hapi.fhir.enable_repository_validating_interceptor=true", - "hapi.fhir.fhir_version=r4b", - "hapi.fhir.subscription.websocket_enabled=false", - "hapi.fhir.mdm_enabled=false", - "hapi.fhir.cr_enabled=false", - // Override is currently required when using MDM as the construction of the MDM - // beans are ambiguous as they are constructed multiple places. This is evident - // when running in a spring boot environment - "spring.main.allow-bean-definition-overriding=true"}) + "spring.datasource.url=jdbc:h2:mem:dbr4b", + "hapi.fhir.enable_repository_validating_interceptor=true", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", + "hapi.fhir.fhir_version=r4b", + "hapi.fhir.subscription.websocket_enabled=false", + "hapi.fhir.mdm_enabled=false", + "hapi.fhir.cr_enabled=false", + // Override is currently required when using MDM as the construction of the MDM + // beans are ambiguous as they are constructed multiple places. This is evident + // when running in a spring boot environment + "spring.main.allow-bean-definition-overriding=true"}) class ExampleServerR4BIT { private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ExampleServerR4BIT.class); private IGenericClient ourClient; @@ -107,7 +108,6 @@ class ExampleServerR4BIT { } - @BeforeEach void beforeEach() { diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4IT.java index 114f24b..edb0890 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4IT.java @@ -54,6 +54,7 @@ import static org.opencds.cqf.fhir.utility.r4.Parameters.stringPart; RepositoryConfig.class }, properties = { "spring.datasource.url=jdbc:h2:mem:dbr4", + "spring.ai.mcp.server.enabled=false", "hapi.fhir.enable_repository_validating_interceptor=true", "hapi.fhir.fhir_version=r4", "hapi.fhir.subscription.websocket_enabled=true", @@ -70,6 +71,9 @@ import static org.opencds.cqf.fhir.utility.r4.Parameters.stringPart; // beans are ambiguous as they are constructed multiple places. This is evident // when running in a spring boot environment "spring.main.allow-bean-definition-overriding=true", + "management.health.elasticsearch.enabled=false", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", + "management.endpoints.web.exposure.include=*", "hapi.fhir.remote_terminology_service.snomed.system=http://snomed.info/sct", "hapi.fhir.remote_terminology_service.snomed.url=https://tx.fhir.org/r4" }) diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR5IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR5IT.java index f31d003..2ea57b6 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR5IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR5IT.java @@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { "spring.datasource.url=jdbc:h2:mem:dbr5", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", "hapi.fhir.fhir_version=r5", "hapi.fhir.cr_enabled=false", "hapi.fhir.subscription.websocket_enabled=true", diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/McpTests.java b/src/test/java/ca/uhn/fhir/jpa/starter/McpTests.java index 19a8a5f..05f990e 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/McpTests.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/McpTests.java @@ -33,7 +33,7 @@ public class McpTests { var fhirContext = FhirContext.forR4(); - var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint("/mcp/message").build(); + var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint("/mcp/messages").build(); var client = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10)).capabilities(McpSchema.ClientCapabilities.builder().roots(true) // Enable roots capability .sampling().build()).build(); var initializationResult = client.initialize(); diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/MdmTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/MdmTest.java index bd1f58d..4d53428 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/MdmTest.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/MdmTest.java @@ -8,19 +8,15 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.nickname.INicknameSvc; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "hapi.fhir.fhir_version=r4", "hapi.fhir.mdm_enabled=true" }) class MdmTest { @Autowired INicknameSvc nicknameService; - - @Autowired - JpaStorageSettings jpaStorageSettings; @Autowired SubscriptionSettings subscriptionSettings; diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/MultitenantServerR4IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/MultitenantServerR4IT.java index 9233961..3d29465 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/MultitenantServerR4IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/MultitenantServerR4IT.java @@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { "spring.datasource.url=jdbc:h2:mem:dbr4-mt", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", "hapi.fhir.fhir_version=r4", "hapi.fhir.subscription.websocket_enabled=true", "hapi.fhir.cr_enabled=false", diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ParallelUpdatesVersionConflictTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/ParallelUpdatesVersionConflictTest.java index 49305fa..d0f2334 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ParallelUpdatesVersionConflictTest.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ParallelUpdatesVersionConflictTest.java @@ -30,7 +30,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { "spring.datasource.url=jdbc:h2:mem:dbr4", "hapi.fhir.fhir_version=r4", - "hapi.fhir.userRequestRetryVersionConflictsInterceptorEnabled=true" + "hapi.fhir.userRequestRetryVersionConflictsInterceptorEnabled=true", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap" }) /** diff --git a/src/test/resources/application.yaml b/src/test/resources/application-test.yaml similarity index 71% rename from src/test/resources/application.yaml rename to src/test/resources/application-test.yaml index 85e7886..1e08a22 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application-test.yaml @@ -1,83 +1,4 @@ -management: - #The following configuration will enable the actuator endpoints at /actuator/health, /actuator/info, /actuator/prometheus - endpoints: - enabled-by-default: false - web: - exposure: - include: 'info,health,prometheus,metrics' # or '*' for all - endpoint: - info: - enabled: true - metrics: - enabled: true - health: - enabled: true - probes: - enabled: true - group: - liveness: - include: - - livenessState - - readinessState - prometheus: - enabled: true - prometheus: - metrics: - export: - enabled: true -spring: - main: - allow-circular-references: true - allow-bean-definition-overriding: true - flyway: - enabled: false - fail-on-missing-locations: false - baselineOnMigrate: true - datasource: - url: jdbc:h2:mem:test_mem - username: sa - password: null - driverClassName: org.h2.Driver - max-active: 15 - # database connection pool size - hikari: - maximum-pool-size: 10 - jpa: - properties: - hibernate.format_sql: false - hibernate.show_sql: false - - ######################################### - # Hibernate Dialect Setting - ######################################### - # Use one of the following values: - # ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect - # ca.uhn.fhir.jpa.model.dialect.HapiFhirDerbyDialect - # ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect - # ca.uhn.fhir.jpa.model.dialect.HapiFhirOracleDialect - # ca.uhn.fhir.jpa.model.dialect.HapiFhirSQLServerDialect - # ca.uhn.fhir.jpa.model.dialect.HapiFhirMySQLDialect (Deprecated!) - ######################################### - hibernate.dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect - ######################################### - # hibernate.hbm2ddl.auto: update - # hibernate.jdbc.batch_size: 20 - # hibernate.cache.use_query_cache: false - # hibernate.cache.use_second_level_cache: false - # hibernate.cache.use_structured_entries: false - # hibernate.cache.use_minimal_puts: false - ### These settings will enable fulltext search with lucene or elastic - hibernate.search.enabled: false - ### lucene parameters - # hibernate.search.backend.type: lucene - # hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer - # hibernate.search.backend.directory.type: local-filesystem - # hibernate.search.backend.directory.root: target/lucenefiles - # hibernate.search.backend.lucene_version: lucene_current - ### elastic parameters ===> see also elasticsearch section below <=== -# hibernate.search.backend.type: elasticsearch -# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer hapi: fhir: From d29b9f80af75e0edf5df599131f86cdd8c1a7fac Mon Sep 17 00:00:00 2001 From: Jens Kristian Villadsen <jenskristianvilladsen@gmail.com> Date: Fri, 26 Sep 2025 19:10:22 +0200 Subject: [PATCH 08/20] Add quick install command for Implementation Guide (#859) Added a one-liner command for installing an Implementation Guide into HAPI. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 711450e..3a0cef4 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,12 @@ docker run -p 8090:8080 -e "--spring.config.location=classpath:/another.applicat ``` Here, the configuration file (*another.application.yaml*) is part of the compiled set of resources. +### One-liner for quickly getting an Implementation Guide installed into HAPI + +``` +docker run -p 8080:8080 -e "hapi.fhir.implementationguides.someIg.name=com.org.something" -e "hapi.fhir.implementationguides.someIg.version=1.2.3" -e "hapi.fhir.implementationguides.someIg.packageUrl=https://build.fhir.org/ig/yourOrg/yourIg/package.tgz" -e "hapi.fhir.implementationguides.someIg.installMode=STORE_AND_INSTALL" hapiproject/hapi:latest +``` + ### Example using ``docker-compose.yml`` for docker-compose ```yaml From d76662c9e9904fc8f142f282f706892c2a37bd9e Mon Sep 17 00:00:00 2001 From: Jens Kristian Villadsen <jenskristianvilladsen@gmail.com> Date: Wed, 1 Oct 2025 22:17:07 +0200 Subject: [PATCH 09/20] Feature/cds config (#857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added MCP support using SSE on http://localhost:8080/sse * Reverted change that IntelliJ complains about * Pre-rework * Cleaned up the code a fair bit * Renamed * Renamed * Running spotless * Reuse FhirContext in result serialization to make MCP server work with R5 * Added support for transactions * PoC tool for CDS Hooks * some cleanup * Upgrade of model protocol * Added comments * Removed field injection ... CDS to be changed to AutoConfig eventually * Adjusted to new builder pattern * Update src/main/java/ca/uhn/fhir/rest/server/MCPBridge.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * A bit of restructuring * More rework * Removing (suspected unnecessary) formatting * Add more example doc * Added a smoke- / passthrough-test * Applied spotless * Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/ToolFactory.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Formatting * Added some documentation * spotless cares about MD? * Reverting back to default values * minor refinements * Fixed CDS hooks configuration * Fixed some wirings * Readded missing elements * getting closer to get test running again ... * applying review * Readded exclude * Bumped spring-ai deps * added agents file * Updated according to review --------- Co-authored-by: Ádám Z. Kövér <adamzkover@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- AGENTS.md | 38 ++ pom.xml | 10 +- .../starter/cdshooks/CdsHooksProperties.java | 2 + .../jpa/starter/cdshooks/CdsHooksServlet.java | 4 - .../cdshooks/ProviderConfiguration.java | 6 +- .../cdshooks/StarterCdsHooksConfig.java | 39 +- .../starter/cdshooks/UpdatedCdsCrService.java | 42 -- .../cdshooks/UpdatedCrDiscoveryService.java | 12 - .../jpa/starter/cr/CareGapsProperties.java | 5 + .../jpa/starter/cr/CqlCompilerProperties.java | 4 + .../ca/uhn/fhir/jpa/starter/cr/CqlData.java | 46 ++ .../fhir/jpa/starter/cr/CqlProperties.java | 14 +- .../jpa/starter/cr/CqlRuntimeProperties.java | 5 + .../starter/cr/CqlTerminologyProperties.java | 43 ++ .../fhir/jpa/starter/cr/CrCommonConfig.java | 26 +- .../uhn/fhir/jpa/starter/cr/CrProperties.java | 5 +- .../fhir/jpa/starter/mcp/McpServerConfig.java | 3 +- src/main/resources/application.yaml | 575 +++++++++--------- src/main/resources/cds.application.yaml | 43 +- .../fhir/jpa/starter/CdsHooksServletIT.java | 183 +++--- 20 files changed, 568 insertions(+), 537 deletions(-) create mode 100644 AGENTS.md delete mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/UpdatedCdsCrService.java delete mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/UpdatedCrDiscoveryService.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlData.java create mode 100644 src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlTerminologyProperties.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..464744a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `src/main/java`: Spring Boot entry point `ca.uhn.fhir.jpa.starter.Application`, resource providers, config. +- `src/main/resources`: Application YAML, search parameter bundles, capability statements packaged with the WAR. +- `src/main/webapp`: HAPI Testpage overlay shipped for the default UI. +- `src/test/java` & `src/test/resources`: JUnit 5 suites (interceptors, MCP, MDM) plus matching fixtures grouped by FHIR version. +- `charts/`, `docker-compose.yml`, `configs/`: Deployment templates for Helm, Docker, and Tomcat/server overrides. +- `Dockerfile`, `build-docker-image.sh`: Reference container build scripts used by CI/CD. + +## Build, Test, and Development Commands +- `mvn clean install`: Compile, run Surefire + Failsafe, and emit `target/ROOT.war`. +- `mvn spring-boot:run -Pboot`: Start the server on port 8080 with hot reload-friendly Boot profile. +- `mvn clean package spring-boot:repackage -Pboot && java -jar target/ROOT.war`: Build and exercise the bootable WAR. +- `docker-compose up -d --build`: Launch JPAServer + PostgreSQL using the local Dockerfile. +- `docker run -p 8080:8080 hapiproject/hapi:latest`: Compare against the upstream binary distribution. + +## Coding Style & Naming Conventions +- Target Java 17, four-space indents, alphabetized imports, no wildcards. +- Keep code under `ca.uhn.fhir.jpa.starter` and mirror packages in tests. +- Prefer descriptive class suffixes (`*Provider`, `*Service`, `*Config`) and constructor injection with `final` collaborators. +- YAML keys stay kebab-case; JSON fixtures use lower_snake_case filenames. + +## Testing Guidelines +- `mvn test`: Runs JUnit Jupiter unit suites such as `CustomBeanTest` and `ParallelUpdatesVersionConflictTest`. +- `mvn verify`: Adds integration coverage through Failsafe with the default H2 datasource; if you pivot to PostgreSQL, run `mvn install -DskipTests` until fixtures are updated. +- Store integration suites as `*IT.java` so Failsafe detects them and colocate datasets in `src/test/resources`. +- Leverage Testcontainers and HAPI FHIR test utilities already declared in `pom.xml`. + +## Commit & Pull Request Guidelines +- Follow repository precedent: imperative summary, optional scope (`Feature/mcp`), and linked issue `(#123)` when applicable. +- Keep commits narrowly scoped and include config or fixture updates with the code they support. +- PRs should describe runtime impact (profiles, ports, env vars), reference issues, and include UI screenshots when behaviour changes. +- Run `mvn verify` or the relevant Docker workflow before review; note any skipped checks and how to reproduce the result. + +## Security & Configuration Tips +- Do not commit secrets; stash overrides under `configs/` and explain required env vars in the PR. +- When enabling external services, update `src/main/resources/application.yaml` plus sample overrides and mention connection expectations for reviewers. diff --git a/pom.xml b/pom.xml index ba38a3b..2c4c609 100644 --- a/pom.xml +++ b/pom.xml @@ -391,20 +391,14 @@ <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp</artifactId> - <version>1.1.0-M1</version> + <version>1.1.0-M2</version> </dependency> <!--implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc:1.1.0-M1")--> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> - <version>1.1.0-M1</version> - </dependency> - - <dependency> - <groupId>io.modelcontextprotocol.sdk</groupId> - <artifactId>mcp</artifactId> - <version>0.12.1</version> + <version>1.1.0-M2</version> </dependency> <dependency> diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/CdsHooksProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/CdsHooksProperties.java index 147336f..1f963fa 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/CdsHooksProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/CdsHooksProperties.java @@ -1,7 +1,9 @@ package ca.uhn.fhir.jpa.starter.cdshooks; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +@Configuration @ConfigurationProperties(prefix = "hapi.fhir.cdshooks") public class CdsHooksProperties { diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/CdsHooksServlet.java b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/CdsHooksServlet.java index bc90000..8bb4ec0 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/CdsHooksServlet.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/CdsHooksServlet.java @@ -2,7 +2,6 @@ package ca.uhn.fhir.jpa.starter.cdshooks; import ca.uhn.fhir.jpa.starter.AppProperties; import ca.uhn.fhir.rest.api.server.cdshooks.CdsServiceRequestJson; -import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; import ca.uhn.hapi.fhir.cdshooks.api.ICdsServiceRegistry; import ca.uhn.hapi.fhir.cdshooks.api.json.CdsServiceResponseJson; @@ -43,9 +42,6 @@ public class CdsHooksServlet extends HttpServlet { @Autowired ICdsServiceRegistry cdsServiceRegistry; - @Autowired - RestfulServer restfulServer; - @Autowired @Qualifier(CDS_HOOKS_OBJECT_MAPPER_FACTORY) ObjectMapper objectMapper; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/ProviderConfiguration.java b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/ProviderConfiguration.java index 334b355..cbfc310 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/ProviderConfiguration.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/ProviderConfiguration.java @@ -1,6 +1,6 @@ package ca.uhn.fhir.jpa.starter.cdshooks; -import ca.uhn.fhir.jpa.starter.cr.CrProperties; +import ca.uhn.fhir.jpa.starter.cr.CqlRuntimeProperties; public class ProviderConfiguration { private final String clientIdHeaderName; @@ -11,8 +11,8 @@ public class ProviderConfiguration { this.clientIdHeaderName = clientIdHeaderName; } - public ProviderConfiguration(CdsHooksProperties cdsProperties, CrProperties crProperties) { - this(crProperties.getCql().getRuntime().isDebugLoggingEnabled(), cdsProperties.getClientIdHeaderName()); + public ProviderConfiguration(CdsHooksProperties cdsProperties, CqlRuntimeProperties cqlRuntimeProperties) { + this(cqlRuntimeProperties.isDebugLoggingEnabled(), cdsProperties.getClientIdHeaderName()); } public String getClientIdHeaderName() { diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/StarterCdsHooksConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/StarterCdsHooksConfig.java index 5da4dae..238cd6b 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/StarterCdsHooksConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/StarterCdsHooksConfig.java @@ -1,20 +1,13 @@ package ca.uhn.fhir.jpa.starter.cdshooks; -import ca.uhn.fhir.context.FhirVersionEnum; -import ca.uhn.fhir.jpa.starter.cr.CrCommonConfig; -import ca.uhn.fhir.jpa.starter.cr.CrConfigCondition; -import ca.uhn.fhir.jpa.starter.cr.CrProperties; +import ca.uhn.fhir.jpa.starter.cr.*; import ca.uhn.hapi.fhir.cdshooks.api.ICdsHooksDaoAuthorizationSvc; +import ca.uhn.hapi.fhir.cdshooks.config.CdsHooksConfig; import ca.uhn.hapi.fhir.cdshooks.svc.CdsHooksContextBooter; import org.hl7.fhir.instance.model.api.IBaseResource; -import org.opencds.cqf.fhir.cr.hapi.cdshooks.CdsCrServiceRegistry; import org.opencds.cqf.fhir.cr.hapi.cdshooks.CdsCrSettings; -import org.opencds.cqf.fhir.cr.hapi.cdshooks.ICdsCrServiceRegistry; -import org.opencds.cqf.fhir.cr.hapi.cdshooks.discovery.CdsCrDiscoveryServiceRegistry; -import org.opencds.cqf.fhir.cr.hapi.cdshooks.discovery.ICdsCrDiscoveryServiceRegistry; import org.opencds.cqf.fhir.cr.hapi.config.CrCdsHooksConfig; import org.opencds.cqf.fhir.cr.hapi.config.RepositoryConfig; -import org.opencds.cqf.fhir.cr.hapi.config.test.TestCdsHooksConfig; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.context.annotation.Bean; @@ -24,30 +17,9 @@ import org.springframework.context.annotation.Import; @Configuration @Conditional({CdsHooksConfigCondition.class, CrConfigCondition.class}) -@Import({RepositoryConfig.class, TestCdsHooksConfig.class, CrCdsHooksConfig.class, CrCommonConfig.class}) +@Import({RepositoryConfig.class, CrCdsHooksConfig.class, CrCommonConfig.class, CdsHooksConfig.class}) public class StarterCdsHooksConfig { - @Bean - public ICdsCrDiscoveryServiceRegistry cdsCrDiscoveryServiceRegistry() { - CdsCrDiscoveryServiceRegistry registry = new CdsCrDiscoveryServiceRegistry(); - registry.unregister(FhirVersionEnum.R4); - registry.register(FhirVersionEnum.R4, UpdatedCrDiscoveryService.class); - return registry; - } - - @Bean - public ICdsCrServiceRegistry cdsCrServiceRegistry() { - CdsCrServiceRegistry registry = new CdsCrServiceRegistry(); - registry.unregister(FhirVersionEnum.R4); - registry.register(FhirVersionEnum.R4, UpdatedCdsCrService.class); - return registry; - } - - @Bean - public CdsHooksProperties cdsHooksProperties() { - return new CdsHooksProperties(); - } - @Bean public CdsCrSettings cdsCrSettings(CdsHooksProperties cdsHooksProperties) { CdsCrSettings settings = CdsCrSettings.getDefault(); @@ -67,8 +39,9 @@ public class StarterCdsHooksConfig { } @Bean - public ProviderConfiguration providerConfiguration(CdsHooksProperties cdsProperties, CrProperties crProperties) { - return new ProviderConfiguration(cdsProperties, crProperties); + public ProviderConfiguration providerConfiguration( + CdsHooksProperties cdsProperties, CqlProperties cqlProperties, CqlRuntimeProperties cqlRuntimeProperties) { + return new ProviderConfiguration(cdsProperties, cqlRuntimeProperties); } @Bean diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/UpdatedCdsCrService.java b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/UpdatedCdsCrService.java deleted file mode 100644 index 4319fa8..0000000 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/UpdatedCdsCrService.java +++ /dev/null @@ -1,42 +0,0 @@ -package ca.uhn.fhir.jpa.starter.cdshooks; - -import ca.uhn.fhir.context.FhirVersionEnum; -import ca.uhn.fhir.repository.IRepository; -import ca.uhn.fhir.rest.api.server.RequestDetails; -import ca.uhn.fhir.rest.api.server.cdshooks.CdsServiceRequestJson; -import org.hl7.fhir.instance.model.api.IBaseParameters; -import org.hl7.fhir.instance.model.api.IPrimitiveType; -import org.opencds.cqf.fhir.cr.hapi.cdshooks.CdsCrService; -import org.opencds.cqf.fhir.utility.adapter.IAdapterFactory; - -import static org.opencds.cqf.fhir.utility.Constants.APPLY_PARAMETER_DATA; - -public class UpdatedCdsCrService extends CdsCrService { - private final IAdapterFactory adapterFactory; - - public UpdatedCdsCrService(RequestDetails theRequestDetails, IRepository theRepository) { - super(theRequestDetails, theRepository); - adapterFactory = IAdapterFactory.forFhirContext(theRepository.fhirContext()); - } - - @Override - public IBaseParameters encodeParams(CdsServiceRequestJson theJson) { - var parameters = adapterFactory.createParameters(super.encodeParams(theJson)); - if (parameters.hasParameter(APPLY_PARAMETER_DATA)) { - parameters.addParameter( - "useServerData", - booleanTypeForVersion(parameters.fhirContext().getVersion().getVersion(), false)); - } - return (IBaseParameters) parameters.get(); - } - - private IPrimitiveType<Boolean> booleanTypeForVersion(FhirVersionEnum fhirVersion, boolean value) { - return switch (fhirVersion) { - case DSTU2 -> new org.hl7.fhir.dstu2.model.BooleanType(value); - case DSTU3 -> new org.hl7.fhir.dstu3.model.BooleanType(value); - case R4 -> new org.hl7.fhir.r4.model.BooleanType(value); - case R5 -> new org.hl7.fhir.r5.model.BooleanType(value); - default -> throw new IllegalArgumentException("unknown or unsupported FHIR version"); - }; - } -} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/UpdatedCrDiscoveryService.java b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/UpdatedCrDiscoveryService.java deleted file mode 100644 index 2bc82f0..0000000 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/UpdatedCrDiscoveryService.java +++ /dev/null @@ -1,12 +0,0 @@ -package ca.uhn.fhir.jpa.starter.cdshooks; - -import ca.uhn.fhir.repository.IRepository; -import org.hl7.fhir.instance.model.api.IIdType; -import org.opencds.cqf.fhir.cr.hapi.cdshooks.discovery.CrDiscoveryService; - -public class UpdatedCrDiscoveryService extends CrDiscoveryService { - public UpdatedCrDiscoveryService(IIdType thePlanDefinitionId, IRepository theRepository) { - super(thePlanDefinitionId, theRepository); - maxUriLength = 6000; - } -} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CareGapsProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CareGapsProperties.java index f25d7b2..e1ebdf9 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CareGapsProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CareGapsProperties.java @@ -1,5 +1,10 @@ package ca.uhn.fhir.jpa.starter.cr; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "hapi.fhir.cr.caregaps") public class CareGapsProperties { private String reporter = "default"; private String section_author = "default"; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlCompilerProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlCompilerProperties.java index f392b04..97d39e3 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlCompilerProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlCompilerProperties.java @@ -3,7 +3,11 @@ package ca.uhn.fhir.jpa.starter.cr; import org.cqframework.cql.cql2elm.CqlCompilerException; import org.cqframework.cql.cql2elm.CqlTranslator; import org.cqframework.cql.cql2elm.LibraryBuilder; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +@Configuration +@ConfigurationProperties(prefix = "hapi.fhir.cr.cql.compiler") public class CqlCompilerProperties { private Boolean validate_units = true; private Boolean verify_only = false; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlData.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlData.java new file mode 100644 index 0000000..0bbf291 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlData.java @@ -0,0 +1,46 @@ +package ca.uhn.fhir.jpa.starter.cr; + +import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "hapi.fhir.cr.cql.data") +public class CqlData { + private RetrieveSettings.SEARCH_FILTER_MODE searchParameterMode = RetrieveSettings.SEARCH_FILTER_MODE.AUTO; + private RetrieveSettings.PROFILE_MODE profileMode = RetrieveSettings.PROFILE_MODE.OFF; + private RetrieveSettings.TERMINOLOGY_FILTER_MODE terminologyParameterMode = + RetrieveSettings.TERMINOLOGY_FILTER_MODE.AUTO; + + public RetrieveSettings.SEARCH_FILTER_MODE getSearchParameterMode() { + return searchParameterMode; + } + + public void setSearchParameterMode(RetrieveSettings.SEARCH_FILTER_MODE searchParameterMode) { + this.searchParameterMode = searchParameterMode; + } + + public RetrieveSettings.PROFILE_MODE getProfileMode() { + return profileMode; + } + + public void setProfileMode(RetrieveSettings.PROFILE_MODE profileMode) { + this.profileMode = profileMode; + } + + public RetrieveSettings.TERMINOLOGY_FILTER_MODE getTerminologyParameterMode() { + return terminologyParameterMode; + } + + public void setTerminologyParameterMode(RetrieveSettings.TERMINOLOGY_FILTER_MODE terminologyParameterMode) { + this.terminologyParameterMode = terminologyParameterMode; + } + + public RetrieveSettings getRetrieveSettings() { + var retrieveSettings = new RetrieveSettings(); + retrieveSettings.setSearchParameterMode(searchParameterMode); + retrieveSettings.setProfileMode(profileMode); + retrieveSettings.setTerminologyParameterMode(terminologyParameterMode); + return retrieveSettings; + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlProperties.java index 845e0c3..488d5c1 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlProperties.java @@ -2,14 +2,18 @@ package ca.uhn.fhir.jpa.starter.cr; import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings; import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +@Configuration +@ConfigurationProperties(prefix = "hapi.fhir.cr.cql") public class CqlProperties { private Boolean use_embedded_libraries = true; private CqlCompilerProperties compiler = new CqlCompilerProperties(); private CqlRuntimeProperties runtime = new CqlRuntimeProperties(); private TerminologySettings terminology = new TerminologySettings(); - private RetrieveSettings data = new RetrieveSettings(); + private CqlData data = new CqlData(); public Boolean getUse_embedded_libraries() { return use_embedded_libraries; @@ -43,11 +47,15 @@ public class CqlProperties { this.terminology = terminology; } - public RetrieveSettings getData() { + public CqlData getData() { return data; } - public void setData(RetrieveSettings data) { + public void setData(CqlData data) { this.data = data; } + + public RetrieveSettings getRetrieveSettings() { + return data.getRetrieveSettings(); + } } diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlRuntimeProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlRuntimeProperties.java index 0677d05..c6c49b7 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlRuntimeProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlRuntimeProperties.java @@ -1,5 +1,10 @@ package ca.uhn.fhir.jpa.starter.cr; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "hapi.fhir.cr.cql.runtime") public class CqlRuntimeProperties { private Boolean debug_logging_enabled = false; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlTerminologyProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlTerminologyProperties.java new file mode 100644 index 0000000..a8bfd2b --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CqlTerminologyProperties.java @@ -0,0 +1,43 @@ +package ca.uhn.fhir.jpa.starter.cr; + +import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@ConfigurationProperties(prefix = "hapi.fhir.cr.cql.terminology") +@Configuration +public class CqlTerminologyProperties { + + private TerminologySettings.VALUESET_EXPANSION_MODE valuesetExpansionMode = + TerminologySettings.VALUESET_EXPANSION_MODE.AUTO; + private TerminologySettings.VALUESET_MEMBERSHIP_MODE valuesetMembershipMode = + TerminologySettings.VALUESET_MEMBERSHIP_MODE.AUTO; + private TerminologySettings.CODE_LOOKUP_MODE codeLookupMode = TerminologySettings.CODE_LOOKUP_MODE.AUTO; + private TerminologySettings.VALUESET_PRE_EXPANSION_MODE valueSetPreExpansionMode = + TerminologySettings.VALUESET_PRE_EXPANSION_MODE.USE_IF_PRESENT; + + public void setValuesetExpansionMode(TerminologySettings.VALUESET_EXPANSION_MODE valuesetExpansionMode) { + this.valuesetExpansionMode = valuesetExpansionMode; + } + + public void setValuesetMembershipMode(TerminologySettings.VALUESET_MEMBERSHIP_MODE valuesetMembershipMode) { + this.valuesetMembershipMode = valuesetMembershipMode; + } + + public void setCodeLookupMode(TerminologySettings.CODE_LOOKUP_MODE codeLookupMode) { + this.codeLookupMode = codeLookupMode; + } + + public void setValueSetPreExpansionMode(TerminologySettings.VALUESET_PRE_EXPANSION_MODE valueSetPreExpansionMode) { + this.valueSetPreExpansionMode = valueSetPreExpansionMode; + } + + public TerminologySettings getTerminologySettings() { + TerminologySettings settings = new TerminologySettings(); + settings.setValuesetExpansionMode(valuesetExpansionMode); + settings.setValuesetMembershipMode(valuesetMembershipMode); + settings.setCodeLookupMode(codeLookupMode); + settings.setValuesetPreExpansionMode(valueSetPreExpansionMode); + return settings; + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrCommonConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrCommonConfig.java index af3768b..e12c310 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrCommonConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrCommonConfig.java @@ -23,7 +23,6 @@ import org.opencds.cqf.fhir.cr.measure.CareGapsProperties; import org.opencds.cqf.fhir.cr.measure.MeasureEvaluationOptions; import org.opencds.cqf.fhir.utility.ValidationProfile; import org.opencds.cqf.fhir.utility.client.TerminologyServerClientSettings; -import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -43,19 +42,13 @@ import java.util.concurrent.Executors; public class CrCommonConfig { @Bean - @ConfigurationProperties(prefix = "hapi.fhir.cr") - CrProperties crProperties() { - return new CrProperties(); + RetrieveSettings retrieveSettings(CqlData cqlData) { + return cqlData.getRetrieveSettings(); } @Bean - RetrieveSettings retrieveSettings(CrProperties theCrProperties) { - return theCrProperties.getCql().getData(); - } - - @Bean - TerminologySettings terminologySettings(CrProperties theCrProperties) { - return theCrProperties.getCql().getTerminology(); + TerminologySettings terminologySettings(CqlTerminologyProperties theCqlTerminologyProperties) { + return theCqlTerminologyProperties.getTerminologySettings(); } @Bean @@ -65,7 +58,8 @@ public class CrCommonConfig { @Bean public EvaluationSettings evaluationSettings( - CrProperties theCrProperties, + CqlRuntimeProperties cqlRuntimeProperties, + CqlCompilerProperties cqlCompilerProperties, RetrieveSettings theRetrieveSettings, TerminologySettings theTerminologySettings, Map<VersionedIdentifier, CompiledLibrary> theGlobalLibraryCache, @@ -76,7 +70,7 @@ public class CrCommonConfig { var cqlEngineOptions = cqlOptions.getCqlEngineOptions(); Set<CqlEngine.Options> options = EnumSet.noneOf(CqlEngine.Options.class); - var cqlRuntimeProperties = theCrProperties.getCql().getRuntime(); + if (cqlRuntimeProperties.isEnableExpressionCaching()) { options.add(CqlEngine.Options.EnableExpressionCaching); } @@ -91,8 +85,6 @@ public class CrCommonConfig { var cqlCompilerOptions = new CqlCompilerOptions(); - var cqlCompilerProperties = theCrProperties.getCql().getCompiler(); - if (cqlCompilerProperties.isEnableDateRangeOptimization()) { cqlCompilerOptions.setOptions(CqlCompilerOptions.Options.EnableDateRangeOptimization); } @@ -159,8 +151,8 @@ public class CrCommonConfig { return executor; } - @Bean - CareGapsProperties careGapsProperties(CrProperties theCrProperties) { + @Bean(name = "measure.CareGapsProperties") + org.opencds.cqf.fhir.cr.measure.CareGapsProperties careGapsProperties(CrProperties theCrProperties) { var careGapsProperties = new CareGapsProperties(); // This check for the resource type really should be happening down in CR where the setting is actually used but // that will have to wait for a future CR release diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java index e4af126..fa76c0f 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java @@ -1,13 +1,16 @@ package ca.uhn.fhir.jpa.starter.cr; import org.opencds.cqf.fhir.utility.client.TerminologyServerClientSettings; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +@Configuration +@ConfigurationProperties(prefix = "hapi.fhir.cr") public class CrProperties { private Boolean enabled; private CareGapsProperties careGaps = new CareGapsProperties(); private CqlProperties cql = new CqlProperties(); - private TerminologyServerClientSettings terminologyServerClientSettings = new TerminologyServerClientSettings(); public Boolean getEnabled() { diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java index 1254f43..2cb3350 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java @@ -8,6 +8,7 @@ import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.hapi.fhir.cdshooks.api.ICdsServiceRegistry; import ca.uhn.hapi.fhir.cdshooks.module.CdsHooksObjectMapperFactory; import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerStreamableHttpProperties; @@ -66,7 +67,7 @@ public class McpServerConfig { return HttpServletStreamableServerTransportProvider.builder() .disallowDelete(false) .mcpEndpoint(properties.getMcpEndpoint()) - .objectMapper(new ObjectMapper()) + .jsonMapper(new JacksonMcpJsonMapper(new ObjectMapper())) // .contextExtractor((serverRequest, context) -> context) .build(); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3a9ac8a..967ba37 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,24 +1,22 @@ -#Uncomment the "servlet" and "context-path" lines below to make the fhir endpoint available at /example/path/fhir instead of the default value of /fhir +# ------------------------------------------------------------------------------------- +# Server & Spring Boot +# ------------------------------------------------------------------------------------- server: - # servlet: - # context-path: /example/path + # Uncomment to serve FHIR under a non-default context path (e.g., /example/path/fhir) + # servlet: + # context-path: /example/path port: 8080 tomcat: - # allow | as a separator in the URL + # allow | as a separator in URLs relaxed-query-chars: "|" -#Adds the option to go to e.g. http://localhost:8080/actuator/health for seeing the running configuration -#see https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints + management: - health: - elasticsearch: - enabled: false - #The following configuration will enable the actuator endpoints at /actuator/health, /actuator/info, /actuator/prometheus, /actuator/metrics. For security purposes, only /actuator/health is enabled by default. + # Actuator endpoints: only /actuator/health exposed by default endpoints: enabled-by-default: false web: exposure: - # expose only health (default) — change to [health,info,prometheus,metrics] if you want them reachable - include: health + include: "health" # or "info,health,prometheus,metrics" or "*" for all endpoint: info: enabled: true @@ -30,40 +28,42 @@ management: enabled: true group: liveness: - include: - - livenessState - - readinessState + include: [ "livenessState", "readinessState" ] prometheus: enabled: true prometheus: metrics: export: enabled: true -spring: - ai: - # Run e.g. `npx @modelcontextprotocol/inspector` and connect to http://localhost:8080/mcp/message using Streamable HTTP -# Add the following to the MCP server settings file in e.g. cursor or claude (Desktop applications) for local debugging: -# cursor: -# { -# "mcpServers": { -# "hapi": { -# "url": "http://localhost:8080/mcp/message" -# } -# } -# } -# or claude: -# { -# "mcpServers": { -# "hapi": { -# "command": "npx", -# "args": [ -# "mcp-remote@latest", -# "http://localhost:8080/mcp/message" -# ] -# } -# } -# } +spring: + # ------------------------------------------------------------------------------- + # A. Spring AI — Model Context Protocol (MCP) + # ------------------------------------------------------------------------------- + ai: + # Run e.g. `npx @modelcontextprotocol/inspector` and connect to http://localhost:8080/mcp/messages using Streamable HTTP + + # Add the following to the MCP server settings file in e.g. cursor or claude (Desktop applications) for local debugging: + # cursor: + # { + # "mcpServers": { + # "hapi": { + # "url": "http://localhost:8080/mcp/messages" + # } + # } + # } + # or claude: + # { + # "mcpServers": { + # "hapi": { + # "command": "npx", + # "args": [ + # "mcp-remote@latest", + # "http://localhost:8080/mcp/messages" + # ] + # } + # } + # } mcp: server: @@ -75,48 +75,43 @@ spring: mcp-endpoint: /mcp/messages - #schema: - # fhir-enabled: true - # fhir: - # base-url: http://localhost:8080/fhir - - #query: - # prompt: - # template: | - # You are a FHIR assistant. Translate the following question into a valid FHIR RESTful API query: - # "{{query}}" - # Use the provided FHIR schema: - # {{schema}} - #base-url: /api/v1 - + # ------------------------------------------------------------------------------- + # B. Core Spring + # ------------------------------------------------------------------------------- + main: + allow-bean-definition-overriding: false + allow-circular-references: true autoconfigure: # This exclude is only needed for setups not using Elasticsearch where the elasticsearch sniff is not needed. exclude: org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration - main: - allow-bean-definition-overriding: false - allow-circular-references: true + flyway: enabled: false baseline-on-migrate: true fail-on-missing-locations: false + datasource: - #url: 'jdbc:h2:file:./target/database/h2' + # url: "jdbc:h2:file:./target/database/h2" url: jdbc:h2:mem:test_mem username: sa password: null driver-class-name: org.h2.Driver - - # database connection pool size + # max-active: 15 # (ignored with HikariCP; use hikari.maximum-pool-size) hikari: maximum-pool-size: 10 - # elasticsearch: - # uris: http://localhost:9200 - # username: elastic - # password: changeme + jpa: properties: hibernate: + format_sql: false + show_sql: false + # Hibernate dialect is auto-detected except for H2/Postgres. + # If using H2: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect + # If using Postgres: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect + dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect + + # --- Optional Hibernate DDL & tuning (commented out from source) --- hbm2ddl: auto: update jdbc: @@ -126,43 +121,73 @@ spring: use_second_level_cache: false use_structured_entries: false use_minimal_puts: false - format_sql: false - show_sql: false - #If using H2, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect - #If using postgres, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect - #dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect - dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect + + # --- Hibernate Search (Lucene/Elasticsearch) --- search: - enabled: true - schema_management: - strategy: create - ### lucene parameters - backend: - type: lucene - directory: - type: local-filesystem - root: target/lucenefiles - analysis: - configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer - - ### elastic parameters ===> see also elasticsearch section below <=== -# backend: -# type: elasticsearch -# discovery: true -# analysis: -# configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer -# hosts: localhost:9200 -# protocol: http -# username: elastic -# password: changeme -# refresh_after_write: true + enabled: false + # Lucene backend (default example) + # backend: + # type: lucene + # analysis: + # configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer + # directory: + # type: local-filesystem + # 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 +# ------------------------------------------------------------------------------------- +# HAPI FHIR — grouped by domain +# ------------------------------------------------------------------------------------- hapi: fhir: - ### This flag when enabled to true, will avail evaluate measure operations from CR Module. - ### Flag is false by default, can be passed as command line argument to override. + + # ------------------------------------------------------------------------------- + # A. Core Server & API + # ------------------------------------------------------------------------------- + openapi_enabled: true # Swagger UI at /fhir/swagger-ui/index.html; API docs at /fhir/api-docs + fhir_version: R4 # DSTU2 | DSTU3 | R4 | R5 + # use_apache_address_strategy: false + # use_apache_address_strategy_https: false + # custom_content_path: ./custom # folder name must be 'custom' + # app_content_path: ./configs/app # served under /web/app + # server_address: http://hapi.fhir.org/baseR4 + # defer_indexing_for_codesystems_of_size: 101 + + # ------------------------------------------------------------------------------- + # B. Implementation Guides (IG) & Package Install + # ------------------------------------------------------------------------------- + ig_runtime_upload_enabled: false + # validate_resource_status_for_package_upload: false # default true + # install_transitive_ig_dependencies: true + # implementationguides: + # swiss: + # name: swiss.mednet.fhir + # version: 0.8.0 + # reloadExisting: false + # installMode: STORE_AND_INSTALL + # ips_1_0_0: + # packageUrl: https://costateixeira.github.io/smart-ips-pilgrimage-fulltest/package.tgz + # name: smart.who.int.ips-pilgrimage-test + # version: 0.1.0 + # installMode: STORE_AND_INSTALL + # additionalResourceFolders: + # - example + # - example2 + # supported_resource_types: + # - Patient + # - Observation + # allowed_bundle_types: COLLECTION,DOCUMENT,MESSAGE,TRANSACTION,TRANSACTIONRESPONSE,BATCH,BATCHRESPONSE,HISTORY,SEARCHSET + + # ------------------------------------------------------------------------------- + # C. Clinical Reasoning / CQL / Care Gaps / CDS Hooks + # ------------------------------------------------------------------------------- cr: - enabled: false + enabled: false # exposes Clinical Reasoning operation endpoints caregaps: reporter: "default" section_author: "default" @@ -174,8 +199,7 @@ hapi: cql: use_embedded_libraries: true compiler: - ### These are low-level compiler options. - ### They are not typically needed by most users. + # low-level compiler options (typically not needed) # validate_units: true # verify_only: false # compatibility_level: "1.5" @@ -200,124 +224,84 @@ hapi: debug_logging_enabled: false # enable_validation: false # enable_expression_caching: true - terminology: - valueset_preexpansion_mode: REQUIRE # USE_IF_PRESENT, REQUIRE, IGNORE - valueset_expansion_mode: PERFORM_NAIVE_EXPANSION # AUTO, USE_EXPANSION_OPERATION, PERFORM_NAIVE_EXPANSION - valueset_membership_mode: USE_EXPANSION # AUTO, USE_VALIDATE_CODE_OPERATION, USE_EXPANSION - code_lookup_mode: USE_VALIDATE_CODE_OPERATION # AUTO, USE_VALIDATE_CODE_OPERATION, USE_CODESYSTEM_URL - data: - search_parameter_mode: USE_SEARCH_PARAMETERS # AUTO, USE_SEARCH_PARAMETERS, FILTER_IN_MEMORY - terminology_parameter_mode: FILTER_IN_MEMORY # AUTO, USE_VALUE_SET_URL, USE_INLINE_CODES, FILTER_IN_MEMORY - profile_mode: DECLARED # ENFORCED, DECLARED, OPTIONAL, TRUST, OFF - + terminology: + valueset_preexpansion_mode: REQUIRE # USE_IF_PRESENT | REQUIRE | IGNORE + valueset_expansion_mode: PERFORM_NAIVE_EXPANSION # AUTO | USE_EXPANSION_OPERATION | PERFORM_NAIVE_EXPANSION + valueset_membership_mode: USE_EXPANSION # AUTO | USE_VALIDATE_CODE_OPERATION | USE_EXPANSION + code_lookup_mode: USE_VALIDATE_CODE_OPERATION # AUTO | USE_VALIDATE_CODE_OPERATION | USE_CODESYSTEM_URL + data: + search_parameter_mode: USE_SEARCH_PARAMETERS # AUTO | USE_SEARCH_PARAMETERS | FILTER_IN_MEMORY + terminology_parameter_mode: FILTER_IN_MEMORY # AUTO | USE_VALUE_SET_URL | USE_INLINE_CODES | FILTER_IN_MEMORY + profile_mode: DECLARED # ENFORCED | DECLARED | OPTIONAL | TRUST | OFF cdshooks: enabled: false clientIdHeaderName: client_id - ### This enables the swagger-ui at /fhir/swagger-ui/index.html as well as the /fhir/api-docs (see https://hapifhir.io/hapi-fhir/docs/server_plain/openapi.html) - openapi_enabled: true - ### This is the FHIR version. Choose between, DSTU2, DSTU3, R4 or R5 - fhir_version: R4 - ### Flag is false by default. This flag enables runtime installation of IG's. - ig_runtime_upload_enabled: false - ### This flag when enabled to true, will avail evaluate measure operations from CR Module. - - ### enable to use the ApacheProxyAddressStrategy which uses X-Forwarded-* headers - ### to determine the FHIR server address - # use_apache_address_strategy: false - ### forces the use of the https:// protocol for the returned server address. - ### alternatively, it may be set using the X-Forwarded-Proto header. - # use_apache_address_strategy_https: false - ### enables the server to overwrite defaults on HTML, css, etc. under the url pattern of eg. /content/custom ** - ### Folder with custom content MUST be named custom. If omitted then default content applies - #custom_content_path: ./custom - ### enables the server host custom content. If e.g. the value ./configs/app is supplied then the content - ### will be served under /web/app - #app_content_path: ./configs/app - ### enable to set the Server URL - # server_address: http://hapi.fhir.org/baseR4 - # defer_indexing_for_codesystems_of_size: 101 - ### Flag is true by default. This flag filters resources during package installation, allowing only those resources with a valid status (e.g. active) to be installed. - # validate_resource_status_for_package_upload: false - # install_transitive_ig_dependencies: true - #implementationguides: - ### example from registry (packages.fhir.org) - # swiss: - # name: swiss.mednet.fhir - # version: 0.8.0 - # reloadExisting: false - # installMode: STORE_AND_INSTALL - # example not from registry - # ips_1_0_0: - # packageUrl: https://costateixeira.github.io/smart-ips-pilgrimage-fulltest/package.tgz - # name: smart.who.int.ips-pilgrimage-test - # version: 0.1.0 - # installMode: STORE_AND_INSTALL - # additionalResourceFolders: - # - example - # - example2 - # supported_resource_types: - # - Patient - # - Observation - ################################################## - # Allowed Bundle Types for persistence (defaults are: COLLECTION,DOCUMENT,MESSAGE) - ################################################## - # allowed_bundle_types: COLLECTION,DOCUMENT,MESSAGE,TRANSACTION,TRANSACTIONRESPONSE,BATCH,BATCHRESPONSE,HISTORY,SEARCHSET - # allow_cascading_deletes: true - # allow_contains_searches: true - # allow_external_references: true - # allow_multiple_delete: true - # allow_override_default_search_params: true - # auto_create_placeholder_reference_targets: false - # mass_ingestion_mode_enabled: false - ### tells the server to automatically append the current version of the target resource to references at these paths - # auto_version_reference_at_paths: Device.patient, Device.location, Device.parent, DeviceMetric.parent, DeviceMetric.source, Observation.device, Observation.subject - # ips_enabled: false - # default_encoding: JSON - # default_pretty_print: true - # default_page_size: 20 - # delete_enabled: true - # delete_expunge_enabled: true - # match_url_cache_enabled: false - # enable_repository_validating_interceptor: true - ### Reduce the size used by search indexes by not tagging every row with the resource type and parameter name (this setting makes manual inspection of the database more difficult, but does not impact HAPI FHIR functionality in any way) - # index_storage_optimized: false - # enable_index_missing_fields: false - # enable_index_of_type: true - # enable_index_contained_resource: false - # upliftedRefchains_enabled: true - # resource_dbhistory_enabled: false - ### !!Extended Lucene/Elasticsearch Indexing is still a experimental feature, expect some features (e.g. _total=accurate) to not work as expected!! - ### more information here: https://hapifhir.io/hapi-fhir/docs/server_jpa/elastic.html + # ------------------------------------------------------------------------------- + # D. Search & Indexing + # ------------------------------------------------------------------------------- + # 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 + # language_search_parameter_enabled: true + # upliftedRefchains_enabled: true + # index_storage_optimized: false + # enable_index_missing_fields: false + # enable_index_of_type: true + # enable_index_contained_resource: false + # store_resource_in_lucene_index_enabled: true + + # ------------------------------------------------------------------------------- + # E. Bulk Operations + # ------------------------------------------------------------------------------- bulk_export_enabled: false bulk_import_enabled: false - # language_search_parameter_enabled: true - # enforce_referential_integrity_on_delete: false - # This is an experimental feature, and does not fully support _total and other FHIR features. - # enforce_referential_integrity_on_delete: false - # enforce_referential_integrity_on_write: false - # etag_support_enabled: true - # expunge_enabled: true - # client_id_strategy: ALPHANUMERIC - # server_id_strategy: SEQUENTIAL_NUMERIC - # fhirpath_interceptor_enabled: false - # filter_search_enabled: true - # graphql_enabled: true + + # ------------------------------------------------------------------------------- + # F. Write / Delete / Integrity + # ------------------------------------------------------------------------------- + # allow_cascading_deletes: true + # allow_contains_searches: true + # allow_external_references: true + # allow_multiple_delete: true + # allow_override_default_search_params: true + # auto_create_placeholder_reference_targets: false + # mass_ingestion_mode_enabled: false + # auto_version_reference_at_paths: Device.patient, Device.location, Device.parent, DeviceMetric.parent, DeviceMetric.source, Observation.device, Observation.subject + # client_id_strategy: ALPHANUMERIC + # server_id_strategy: SEQUENTIAL_NUMERIC + # enforce_referential_integrity_on_delete: false + # enforce_referential_integrity_on_write: false + # etag_support_enabled: true + # expunge_enabled: true + # fhirpath_interceptor_enabled: false + # filter_search_enabled: true + # graphql_enabled: true + + # ------------------------------------------------------------------------------- + # G. Narrative & Validation + # ------------------------------------------------------------------------------- narrative_enabled: false + # validation: + # requests_enabled: true + # responses_enabled: true + + # ------------------------------------------------------------------------------- + # H. MDM (Master Data Management) + # ------------------------------------------------------------------------------- mdm_enabled: false mdm_rules_json_location: "mdm-rules.json" - ## see: https://hapifhir.io/hapi-fhir/docs/interceptors/built_in_server_interceptors.html#jpa-server-retry-on-version-conflicts - # userRequestRetryVersionConflictsInterceptorEnabled : false - # local_base_urls: - # - https://hapi.fhir.org/baseR4 - # pre_expand_value_sets: true - # enable_task_pre_expand_value_sets: true - # pre_expand_value_sets_default_count: 1000 - # pre_expand_value_sets_max_count: 1000 - # maximum_expansion_size: 1000 + # userRequestRetryVersionConflictsInterceptorEnabled: false + # ------------------------------------------------------------------------------- + # I. Terminology / ValueSet Expansion + # ------------------------------------------------------------------------------- + # pre_expand_value_sets: true + # enable_task_pre_expand_value_sets: true + # pre_expand_value_sets_default_count: 1000 + # pre_expand_value_sets_max_count: 1000 + # maximum_expansion_size: 1000 logical_urls: - http://terminology.hl7.org/* - https://terminology.hl7.org/* @@ -328,38 +312,32 @@ hapi: - http://loinc.org/* - https://loinc.org/* - ### Uncomment the following section, and any sub-properties you need in order to enable - ### partitioning support on this server. - #partitioning: - # allow_references_across_partitions: false - # partitioning_include_in_search_hashes: false - # default_partition_id: 0 - ### Enable the following setting to enable Database Partitioning Mode - ### See: https://hapifhir.io/hapi-fhir/docs/server_jpa_partitioning/db_partition_mode.html - # database_partition_mode_enabled: true - ### Partition Style: Partitioning requires a partition interceptor which helps the server - ### select which partition(s) should be accessed for a given request. You can supply your - ### own interceptor (see https://hapifhir.io/hapi-fhir/docs/server_jpa_partitioning/partitioning.html#partition-interceptors ) - ### but the following setting can also be used to use a built-in form. - ### Patient ID Partitioning Mode uses the patient/subject ID to determine the partition - # patient_id_partitioning_mode: true - ### Request tenant mode can be used for a multi-tenancy setup where the request path is - ### expected to have an additional path element, e.g. GET http://example.com/fhir/TENANT-ID/Patient/A - # request_tenant_partitioning_mode: false + # ------------------------------------------------------------------------------- + # J. Partitioning & Multitenancy + # ------------------------------------------------------------------------------- + # partitioning: + # allow_references_across_partitions: false + # partitioning_include_in_search_hashes: false + # default_partition_id: 0 + # database_partition_mode_enabled: true + # patient_id_partitioning_mode: true + # request_tenant_partitioning_mode: false + # ------------------------------------------------------------------------------- + # K. CORS + # ------------------------------------------------------------------------------- cors: allow_Credentials: true - # These are allowed_origin patterns, see: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/cors/CorsConfiguration.html#setAllowedOriginPatterns-java.util.List- allowed_origin: - - '*' + - "*" - # Search coordinator thread pool sizes + # ------------------------------------------------------------------------------- + # L. Search Orchestration + # ------------------------------------------------------------------------------- search-coord-core-pool-size: 20 search-coord-max-pool-size: 100 search-coord-queue-capacity: 200 - # Search Prefetch Thresholds. - # This setting sets the number of search results to prefetch. For example, if this list # is set to [100, 1000, -1] then the server will initially load 100 results and not # attempt to load more. If the user requests subsequent page(s) of results and goes @@ -367,87 +345,78 @@ hapi: # The system will progressively work through these thresholds. # A threshold of -1 means to load all results. Note that if the final threshold is a # number other than -1, the system will never prefetch more than the given number. + # CSV list; -1 as final value means "all" search_prefetch_thresholds: 13,503,2003,-1 + # ------------------------------------------------------------------------------- + # M. Extensibility (custom beans / interceptors / providers) + # ------------------------------------------------------------------------------- # comma-separated package names, will be @ComponentScan'ed by Spring to allow for creating custom Spring beans - #custom-bean-packages: + + # custom-provider-classes: + # custom-interceptor-classes: + # custom-provider-classes: - # comma-separated list of fully qualified interceptor classes. - # classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages', - # or will be instantiated via reflection using a no-arg constructor; then registered with the server - #custom-interceptor-classes: - - # comma-separated list of fully qualified provider classes. - # classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages', - # or will be instantiated via reflection using a no-arg constructor; then registered with the server - #custom-provider-classes: - # specify what should be stored in meta.source based on StoreMetaSourceInformationEnum defaults to NONE # store_meta_source_information: NONE - # Threadpool size for BATCH'ed GETs in a bundle. - # bundle_batch_pool_size: 10 - # bundle_batch_pool_max_size: 50 + # bundle_batch_pool_size: 10 + # bundle_batch_pool_max_size: 50 - # logger: - # error_format: 'ERROR - ${requestVerb} ${requestUrl}' - # format: >- - # Path[${servletPath}] Source[${requestHeader.x-forwarded-for}] - # Operation[${operationType} ${operationName} ${idOrResourceName}] - # UA[${requestHeader.user-agent}] Params[${requestParameters}] - # ResponseEncoding[${responseEncodingNoDefault}] - # log_exceptions: true - # name: fhirtest.access - # max_binary_size: 104857600 - # max_page_size: 200 - # retain_cached_searches_mins: 60 - # reuse_cached_search_results_millis: 60000 - # The remote_terminology_service block is commented out by default because it requires external terminology service endpoints. - # Uncomment and configure the block below if you need to enable remote terminology validation or mapping. - #remote_terminology_service: - # all: - # system: '*' - # url: 'https://tx.fhir.org/r4/' - # snomed: - # system: 'http://snomed.info/sct' - # url: 'https://tx.fhir.org/r4/' - # loinc: - # system: 'http://loinc.org' - # url: 'https://hapi.fhir.org/baseR4/' - tester: - home: - name: Local Tester - server_address: 'http://localhost:8080/fhir' - refuse_to_fetch_third_party_urls: false - fhir_version: R4 - global: - name: Global Tester - server_address: "http://hapi.fhir.org/baseR4" - refuse_to_fetch_third_party_urls: false - fhir_version: R4 - # validation: - # requests_enabled: true - # responses_enabled: true - # binary_storage_enabled: true + # ------------------------------------------------------------------------------- + # N. Logging + # ------------------------------------------------------------------------------- + # logger: + # error_format: "ERROR - ${requestVerb} ${requestUrl}" + # format: >- + # Path[${servletPath}] Source[${requestHeader.x-forwarded-for}] + # Operation[${operationType} ${operationName} ${idOrResourceName}] + # UA[${requestHeader.user-agent}] Params[${requestParameters}] + # ResponseEncoding[${responseEncodingNoDefault}] + # log_exceptions: true + # name: fhirtest.access + + # ------------------------------------------------------------------------------- + # O. Storage / Pagination / Caching + # ------------------------------------------------------------------------------- + # max_binary_size: 104857600 + # max_page_size: 200 + # retain_cached_searches_mins: 60 + # reuse_cached_search_results_millis: 60000 inline_resource_storage_below_size: 4000 -# bulk_export_enabled: true -# subscription: -# resthook_enabled: true -# websocket_enabled: false -# polling_interval_ms: 5000 -# immediately_queued: false -# email: -# from: some@test.com -# host: google.com -# port: -# username: -# password: -# auth: -# startTlsEnable: -# startTlsRequired: -# quitWait: -# lastn_enabled: true -# store_resource_in_lucene_index_enabled: true -### This is configuration for normalized quantity search level default is 0 -### 0: NORMALIZED_QUANTITY_SEARCH_NOT_SUPPORTED - default -### 1: NORMALIZED_QUANTITY_STORAGE_SUPPORTED -### 2: NORMALIZED_QUANTITY_SEARCH_SUPPORTED -# normalized_quantity_search_level: 2 + + # ------------------------------------------------------------------------------- + # P. Remote Terminology Service (disabled by default) + # ------------------------------------------------------------------------------- + # remote_terminology_service: + # all: + # system: "*" + # url: "https://tx.fhir.org/r4/" + # snomed: + # system: "http://snomed.info/sct" + # url: "https://tx.fhir.org/r4/" + # loinc: + # system: "http://loinc.org" + # url: "https://hapi.fhir.org/baseR4/" + + # ------------------------------------------------------------------------------- + # Q. Subscriptions (disabled by default) + # ------------------------------------------------------------------------------- + # subscription: + # resthook_enabled: true + # websocket_enabled: false + # polling_interval_ms: 5000 + # immediately_queued: false + # email: + # from: some@test.com + # host: google.com + # port: + # username: + # password: + # auth: + # startTlsEnable: + # startTlsRequired: + # quitWait: + + # ------------------------------------------------------------------------------- + # R. LastN (analytics) + # ------------------------------------------------------------------------------- + # lastn_enabled: true diff --git a/src/main/resources/cds.application.yaml b/src/main/resources/cds.application.yaml index 2a8cf9d..c889fcd 100644 --- a/src/main/resources/cds.application.yaml +++ b/src/main/resources/cds.application.yaml @@ -1,7 +1,7 @@ #Uncomment the "servlet" and "context-path" lines below to make the fhir endpoint available at /example/path/fhir instead of the default value of /fhir server: -# servlet: -# context-path: /example/path + # servlet: + # context-path: /example/path port: 8080 #Adds the option to go to e.g. http://localhost:8080/actuator/health for seeing the running configuration #see https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints @@ -53,31 +53,38 @@ spring: maximum-pool-size: 10 jpa: properties: - hibernate.format_sql: false - hibernate.show_sql: false + hibernate: + format_sql: false + show_sql: false #Hibernate dialect is automatically detected except Postgres and H2. #If using H2, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect #If using postgres, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgres94Dialect - hibernate.dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect - # hibernate.hbm2ddl.auto: update - # hibernate.jdbc.batch_size: 20 - # hibernate.cache.use_query_cache: false - # hibernate.cache.use_second_level_cache: false - # hibernate.cache.use_structured_entries: false - # hibernate.cache.use_minimal_puts: false + dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect + # hibernate.hbm2ddl.auto: update + # hibernate.jdbc.batch_size: 20 + # hibernate.cache.use_query_cache: false + # hibernate.cache.use_second_level_cache: false + # hibernate.cache.use_structured_entries: false + # hibernate.cache.use_minimal_puts: false - ### These settings will enable fulltext search with lucene or elastic - hibernate.search.enabled: true + ### These settings will enable fulltext search with lucene or elastic + + search: + enabled: true ### lucene parameters -# hibernate.search.backend.type: lucene -# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer -# hibernate.search.backend.directory.type: local-filesystem -# hibernate.search.backend.directory.root: target/lucenefiles -# hibernate.search.backend.lucene_version: lucene_current + # hibernate.search.backend.type: lucene + # hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer + # hibernate.search.backend.directory.type: local-filesystem + # hibernate.search.backend.directory.root: target/lucenefiles + # hibernate.search.backend.lucene_version: lucene_current ### elastic parameters ===> see also elasticsearch section below <=== # hibernate.search.backend.type: elasticsearch # hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer + autoconfigure: + # This exclude is only needed for setups not using Elasticsearch where the elasticsearch sniff is not needed. + exclude: org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration + hapi: fhir: ### This flag when enabled to true, will avail evaluate measure operations from CR Module. diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java b/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java index 1e77b3a..a51f5e3 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java @@ -19,6 +19,7 @@ import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.opencds.cqf.fhir.cr.hapi.config.CrCdsHooksConfig; import org.opencds.cqf.fhir.cr.hapi.config.RepositoryConfig; @@ -35,25 +36,19 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.fail; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - classes = { - Application.class, - NicknameServiceConfig.class, - RepositoryConfig.class, - TestCdsHooksConfig.class, - CrCdsHooksConfig.class, - StarterCdsHooksConfig.class - }, properties = { - "spring.profiles.include=storageSettingsTest", - "spring.datasource.url=jdbc:h2:mem:dbr4", - "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", - "hapi.fhir.enable_repository_validating_interceptor=true", - "hapi.fhir.fhir_version=r4", - "hapi.fhir.cr.enabled=true", - "hapi.fhir.cr.caregaps.section_author=Organization/alphora-author", - "hapi.fhir.cr.caregaps.reporter=Organization/alphora", - "hapi.fhir.cdshooks.enabled=true", - "spring.main.allow-bean-definition-overriding=true"}) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class, NicknameServiceConfig.class, RepositoryConfig.class, TestCdsHooksConfig.class, CrCdsHooksConfig.class, StarterCdsHooksConfig.class}, + properties = { + "spring.profiles.include=storageSettingsTest", + "spring.datasource.url=jdbc:h2:mem:dbr4", + "spring.jpa.properties.hibernate.search.backend.directory.type=local-heap", + "hapi.fhir.enable_repository_validating_interceptor=true", + "hapi.fhir.fhir_version=r4", + "hapi.fhir.cr.enabled=true", + "hapi.fhir.cr.caregaps.section_author=Organization/alphora-author", + "hapi.fhir.cr.caregaps.reporter=Organization/alphora", + "hapi.fhir.cdshooks.enabled=true", + "spring.main.allow-bean-definition-overriding=true", + "server.max-http-request-header-size=16KB"}) class CdsHooksServletIT implements IServerSupport { private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(CdsHooksServletIT.class); private final FhirContext ourCtx = FhirContext.forR4Cached(); @@ -156,83 +151,87 @@ class CdsHooksServletIT implements IServerSupport { fail(ioe.getMessage()); } } - + @Test void testRec10() throws IOException { loadBundle("r4/opioidcds-10-order-sign-bundle.json", ourCtx, ourClient); await().atMost(20000, TimeUnit.MILLISECONDS).until(this::hasCdsServices); var fhirServer = " \"fhirServer\": " + "\"" + ourServerBase + "\"" + ",\n"; - var cdsRequest = "{\n" + - " \"hookInstance\": \"055b009c-4a7d-4db4-a35e-0e5198918ed1\",\n" + - " \"hook\": \"order-sign\",\n" + - fhirServer + - " \"context\": {\n" + - " \"patientId\": \"example-rec-10-order-sign-illicit-POS-Cocaine-drugs\",\n" + - " \"userId\": \"COREPRACTITIONER1\",\n" + - " \"draftOrders\": {\n" + - " \"resourceType\": \"Bundle\",\n" + - " \"entry\": [\n" + - " {\n" + - " \"resource\": {\n" + - " \"resourceType\": \"MedicationRequest\",\n" + - " \"id\": \"request-123\",\n" + - " \"status\": \"draft\",\n" + - " \"subject\": {\n" + - " \"reference\": \"Patient/example-rec-10-order-sign-illicit-POS-Cocaine-drugs\"\n" + - " },\n" + - " \"authoredOn\": \"2024-03-27\",\n" + - " \"dosageInstruction\": [\n" + - " {\n" + - " \"timing\": {\n" + - " \"repeat\": {\n" + - " \"frequency\": 1,\n" + - " \"period\": 1,\n" + - " \"periodUnit\": \"d\"\n" + - " }\n" + - " },\n" + - " \"doseAndRate\": [\n" + - " {\n" + - " \"doseQuantity\": {\n" + - " \"value\": 1,\n" + - " \"system\": \"http://unitsofmeasure.org\",\n" + - " \"code\": \"{pill}\"\n" + - " }\n" + - " }\n" + - " ]\n" + - " }\n" + - " ],\n" + - " \"dispenseRequest\": {\n" + - " \"expectedSupplyDuration\": {\n" + - " \"value\": 90,\n" + - " \"unit\": \"days\",\n" + - " \"system\": \"http://unitsofmeasure.org\",\n" + - " \"code\": \"d\"\n" + - " }\n" + - " },\n" + - " \"intent\": \"order\",\n" + - " \"category\": {\n" + - " \"coding\": [\n" + - " {\n" + - " \"system\": \"http://terminology.hl7.org/CodeSystem/medicationrequest-category\",\n" + - " \"code\": \"community\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"medicationCodeableConcept\": {\n" + - " \"coding\": [\n" + - " {\n" + - " \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n" + - " \"code\": \"1049502\",\n" + - " \"display\": \"12 HR oxycodone hydrochloride 10 MG Extended Release Oral Tablet\"\n" + - " }\n" + - " ]\n" + - " }\n" + - " }\n" + - " }\n" + - " ]\n" + - " }\n" + - " }\n" + - "}"; + var cdsRequest = """ + { + "hookInstance": "055b009c-4a7d-4db4-a35e-0e5198918ed1", + "hook": "order-sign", + """ + fhirServer + """ + "context": { + "patientId": "example-rec-10-order-sign-illicit-POS-Cocaine-drugs", + "userId": "COREPRACTITIONER1", + "draftOrders": { + "resourceType": "Bundle", + "entry": [ + { + "resource": { + "resourceType": "MedicationRequest", + "id": "request-123", + "status": "draft", + "subject": { + "reference": "Patient/example-rec-10-order-sign-illicit-POS-Cocaine-drugs" + }, + "authoredOn": "2024-03-27", + "dosageInstruction": [ + { + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "doseAndRate": [ + { + "doseQuantity": { + "value": 1, + "system": "http://unitsofmeasure.org", + "code": "{pill}" + } + } + ] + } + ], + "dispenseRequest": { + "expectedSupplyDuration": { + "value": 90, + "unit": "days", + "system": "http://unitsofmeasure.org", + "code": "d" + } + }, + "intent": "order", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/medicationrequest-category", + "code": "community" + } + ] + } + ], + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "1049502", + "display": "12 HR oxycodone hydrochloride 10 MG Extended Release Oral Tablet" + } + ] + } + } + } + ] + } + } + } + """; try (CloseableHttpClient httpClient = HttpClients.createDefault()) { HttpPost request = new HttpPost(ourCdsBase + "/opioidcds-10-order-sign"); request.setEntity(new StringEntity(cdsRequest)); @@ -245,7 +244,7 @@ class CdsHooksServletIT implements IServerSupport { assertNotNull(response); JsonArray cards = response.getAsJsonArray("cards"); assertEquals(0, cards.size()); -// assertEquals("\"Hello World!\"", cards.get(0).getAsJsonObject().get("summary").toString()); + //assertEquals("\"Hello World!\"", cards.get(0).getAsJsonObject().get("summary").toString()); } catch (IOException ioe) { fail(ioe.getMessage()); } From be7b5b321c325957e0a7d01426993ffaa07014e1 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Wed, 10 Sep 2025 11:30:49 -0600 Subject: [PATCH 10/20] Update CR to 3.27.0-SNAPSHOT --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 2c4c609..ec33f13 100644 --- a/pom.xml +++ b/pom.xml @@ -5,8 +5,8 @@ <properties> <java.version>17</java.version> - <hapi.fhir.jpa.server.starter.revision>3</hapi.fhir.jpa.server.starter.revision> - <clinical-reasoning.version>3.26.0</clinical-reasoning.version> + <hapi.fhir.jpa.server.starter.revision>2</hapi.fhir.jpa.server.starter.revision> + <clinical-reasoning.version>3.27.0-SNAPSHOT</clinical-reasoning.version> </properties> <!-- one-liner to take you to the cloud with settings form the application.yaml file: --> From 3673c135b10f2e9eb3f4937780b93cab50e5ed81 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Fri, 26 Sep 2025 09:27:13 -0600 Subject: [PATCH 11/20] Update to 3.27.0 release --- pom.xml | 2 +- src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index ec33f13..ae371cb 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ <properties> <java.version>17</java.version> <hapi.fhir.jpa.server.starter.revision>2</hapi.fhir.jpa.server.starter.revision> - <clinical-reasoning.version>3.27.0-SNAPSHOT</clinical-reasoning.version> + <clinical-reasoning.version>3.27.0</clinical-reasoning.version> </properties> <!-- one-liner to take you to the cloud with settings form the application.yaml file: --> diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java index fa76c0f..f790bd5 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java @@ -11,7 +11,7 @@ public class CrProperties { private CareGapsProperties careGaps = new CareGapsProperties(); private CqlProperties cql = new CqlProperties(); - private TerminologyServerClientSettings terminologyServerClientSettings = new TerminologyServerClientSettings(); + private TerminologyServerClientSettings terminologyServerClientSettings = TerminologyServerClientSettings.getDefault(); public Boolean getEnabled() { return enabled; From 4a1e76cd717d7e37daf92bc7546cd817bff37679 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Fri, 26 Sep 2025 09:31:03 -0600 Subject: [PATCH 12/20] spotless --- src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java index f790bd5..c3b88fd 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cr/CrProperties.java @@ -11,7 +11,8 @@ public class CrProperties { private CareGapsProperties careGaps = new CareGapsProperties(); private CqlProperties cql = new CqlProperties(); - private TerminologyServerClientSettings terminologyServerClientSettings = TerminologyServerClientSettings.getDefault(); + private TerminologyServerClientSettings terminologyServerClientSettings = + TerminologyServerClientSettings.getDefault(); public Boolean getEnabled() { return enabled; From da88010b16cec4411342c0a7bdb2d5c701fbcbb9 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Fri, 26 Sep 2025 14:48:49 -0600 Subject: [PATCH 13/20] fix revision --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ae371cb..b55df6d 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ <properties> <java.version>17</java.version> - <hapi.fhir.jpa.server.starter.revision>2</hapi.fhir.jpa.server.starter.revision> + <hapi.fhir.jpa.server.starter.revision>3</hapi.fhir.jpa.server.starter.revision> <clinical-reasoning.version>3.27.0</clinical-reasoning.version> </properties> From c9b576d979a01dfdc8a4ce40b79b64acca52a327 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Thu, 2 Oct 2025 09:34:32 -0600 Subject: [PATCH 14/20] fix config --- .../fhir/jpa/starter/mcp/McpServerConfig.java | 2 +- src/main/resources/cds.application.yaml | 529 ++++++++++-------- 2 files changed, 307 insertions(+), 224 deletions(-) diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java index 2cb3350..fd6652c 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java @@ -51,7 +51,7 @@ public class McpServerConfig { @Bean @ConditionalOnProperty( - prefix = "hapi.fhir.cr", + prefix = "hapi.fhir.cdshooks", name = {"enabled"}, havingValue = "true") public McpCdsBridge mcpCdsBridge(FhirContext fhirContext, ICdsServiceRegistry cdsServiceRegistry) { diff --git a/src/main/resources/cds.application.yaml b/src/main/resources/cds.application.yaml index c889fcd..82f2f8a 100644 --- a/src/main/resources/cds.application.yaml +++ b/src/main/resources/cds.application.yaml @@ -1,17 +1,22 @@ -#Uncomment the "servlet" and "context-path" lines below to make the fhir endpoint available at /example/path/fhir instead of the default value of /fhir +# ------------------------------------------------------------------------------------- +# Server & Spring Boot +# ------------------------------------------------------------------------------------- server: - # servlet: - # context-path: /example/path + # Uncomment to serve FHIR under a non-default context path (e.g., /example/path/fhir) + # servlet: + # context-path: /example/path port: 8080 -#Adds the option to go to e.g. http://localhost:8080/actuator/health for seeing the running configuration -#see https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints + tomcat: + # allow | as a separator in URLs + relaxed-query-chars: "|" + management: - #The following configuration will enable the actuator endpoints at /actuator/health, /actuator/info, /actuator/prometheus, /actuator/metrics. For security purposes, only /actuator/health is enabled by default. + # Actuator endpoints: only /actuator/health exposed by default endpoints: enabled-by-default: false web: exposure: - include: 'health' # or e.g. 'info,health,prometheus,metrics' or '*' for all + include: "health" # or "info,health,prometheus,metrics" or "*" for all endpoint: info: enabled: true @@ -23,74 +28,166 @@ management: enabled: true group: liveness: - include: - - livenessState - - readinessState + include: [ "livenessState", "readinessState" ] prometheus: enabled: true prometheus: metrics: export: enabled: true + spring: + # ------------------------------------------------------------------------------- + # A. Spring AI — Model Context Protocol (MCP) + # ------------------------------------------------------------------------------- + ai: + # Run e.g. `npx @modelcontextprotocol/inspector` and connect to http://localhost:8080/mcp/messages using Streamable HTTP + + # Add the following to the MCP server settings file in e.g. cursor or claude (Desktop applications) for local debugging: + # cursor: + # { + # "mcpServers": { + # "hapi": { + # "url": "http://localhost:8080/mcp/messages" + # } + # } + # } + # or claude: + # { + # "mcpServers": { + # "hapi": { + # "command": "npx", + # "args": [ + # "mcp-remote@latest", + # "http://localhost:8080/mcp/messages" + # ] + # } + # } + # } + + mcp: + server: + name: FHIR MCP Server + version: 1.0.0 + instructions: "This server provides access to a FHIR RESTful API. You can use it to query FHIR resources, perform operations, and retrieve data in a structured format." + enabled: true + streamable-http: + mcp-endpoint: /mcp/messages + + + # ------------------------------------------------------------------------------- + # B. Core Spring + # ------------------------------------------------------------------------------- main: + allow-bean-definition-overriding: false allow-circular-references: true - allow-bean-definition-overriding: true + + autoconfigure: + # This exclude is only needed for setups not using Elasticsearch where the elasticsearch sniff is not needed. + exclude: org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration + flyway: enabled: false - baselineOnMigrate: true + baseline-on-migrate: true fail-on-missing-locations: false + datasource: - #url: 'jdbc:h2:file:./target/database/h2' + # url: "jdbc:h2:file:./target/database/h2" url: jdbc:h2:mem:test_mem username: sa password: null - driverClassName: org.h2.Driver - max-active: 15 - - # database connection pool size + driver-class-name: org.h2.Driver + # max-active: 15 # (ignored with HikariCP; use hikari.maximum-pool-size) hikari: maximum-pool-size: 10 + jpa: properties: hibernate: format_sql: false show_sql: false - - #Hibernate dialect is automatically detected except Postgres and H2. - #If using H2, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect - #If using postgres, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgres94Dialect + # Hibernate dialect is auto-detected except for H2/Postgres. + # If using H2: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect + # If using Postgres: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect - # hibernate.hbm2ddl.auto: update - # hibernate.jdbc.batch_size: 20 - # hibernate.cache.use_query_cache: false - # hibernate.cache.use_second_level_cache: false - # hibernate.cache.use_structured_entries: false - # hibernate.cache.use_minimal_puts: false - ### These settings will enable fulltext search with lucene or elastic + # --- Optional Hibernate DDL & tuning (commented out from source) --- + hbm2ddl: + auto: update + jdbc: + batch_size: 20 + cache: + use_query_cache: false + use_second_level_cache: false + use_structured_entries: false + use_minimal_puts: false + # --- Hibernate Search (Lucene/Elasticsearch) --- search: - enabled: true - ### lucene parameters - # hibernate.search.backend.type: lucene - # hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer - # hibernate.search.backend.directory.type: local-filesystem - # hibernate.search.backend.directory.root: target/lucenefiles - # hibernate.search.backend.lucene_version: lucene_current - ### elastic parameters ===> see also elasticsearch section below <=== -# hibernate.search.backend.type: elasticsearch -# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer - autoconfigure: - # This exclude is only needed for setups not using Elasticsearch where the elasticsearch sniff is not needed. - exclude: org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration + enabled: false + # Lucene backend (default example) + # backend: + # type: lucene + # analysis: + # configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer + # directory: + # type: local-filesystem + # 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 +# ------------------------------------------------------------------------------------- +# HAPI FHIR — grouped by domain +# ------------------------------------------------------------------------------------- hapi: fhir: - ### This flag when enabled to true, will avail evaluate measure operations from CR Module. - ### Flag is false by default, can be passed as command line argument to override. + + # ------------------------------------------------------------------------------- + # A. Core Server & API + # ------------------------------------------------------------------------------- + openapi_enabled: true # Swagger UI at /fhir/swagger-ui/index.html; API docs at /fhir/api-docs + fhir_version: R4 # DSTU2 | DSTU3 | R4 | R5 + # use_apache_address_strategy: false + # use_apache_address_strategy_https: false + # custom_content_path: ./custom # folder name must be 'custom' + # app_content_path: ./configs/app # served under /web/app + # server_address: http://hapi.fhir.org/baseR4 + # defer_indexing_for_codesystems_of_size: 101 + + # ------------------------------------------------------------------------------- + # B. Implementation Guides (IG) & Package Install + # ------------------------------------------------------------------------------- + ig_runtime_upload_enabled: false + # validate_resource_status_for_package_upload: false # default true + # install_transitive_ig_dependencies: true + # implementationguides: + # swiss: + # name: swiss.mednet.fhir + # version: 0.8.0 + # reloadExisting: false + # installMode: STORE_AND_INSTALL + # ips_1_0_0: + # packageUrl: https://costateixeira.github.io/smart-ips-pilgrimage-fulltest/package.tgz + # name: smart.who.int.ips-pilgrimage-test + # version: 0.1.0 + # installMode: STORE_AND_INSTALL + # additionalResourceFolders: + # - example + # - example2 + # supported_resource_types: + # - Patient + # - Observation + # allowed_bundle_types: COLLECTION,DOCUMENT,MESSAGE,TRANSACTION,TRANSACTIONRESPONSE,BATCH,BATCHRESPONSE,HISTORY,SEARCHSET + + # ------------------------------------------------------------------------------- + # C. Clinical Reasoning / CQL / Care Gaps / CDS Hooks + # ------------------------------------------------------------------------------- cr: - enabled: true + enabled: true # exposes Clinical Reasoning operation endpoints caregaps: reporter: "default" section_author: "default" @@ -102,8 +199,7 @@ hapi: cql: use_embedded_libraries: true compiler: - ### These are low-level compiler options. - ### They are not typically needed by most users. + # low-level compiler options (typically not needed) # validate_units: true # verify_only: false # compatibility_level: "1.5" @@ -128,113 +224,84 @@ hapi: debug_logging_enabled: false # enable_validation: false # enable_expression_caching: true - terminology: - valueset_preexpansion_mode: REQUIRE # USE_IF_PRESENT, REQUIRE, IGNORE - valueset_expansion_mode: PERFORM_NAIVE_EXPANSION # AUTO, USE_EXPANSION_OPERATION, PERFORM_NAIVE_EXPANSION - valueset_membership_mode: USE_EXPANSION # AUTO, USE_VALIDATE_CODE_OPERATION, USE_EXPANSION - code_lookup_mode: USE_CODESYSTEM_URL # AUTO, USE_VALIDATE_CODE_OPERATION, USE_CODESYSTEM_URL - data: - search_parameter_mode: USE_SEARCH_PARAMETERS # AUTO, USE_SEARCH_PARAMETERS, FILTER_IN_MEMORY - terminology_parameter_mode: FILTER_IN_MEMORY # AUTO, USE_VALUE_SET_URL, USE_INLINE_CODES, FILTER_IN_MEMORY - profile_mode: DECLARED # ENFORCED, DECLARED, OPTIONAL, TRUST, OFF - + terminology: + valueset_preexpansion_mode: REQUIRE # USE_IF_PRESENT | REQUIRE | IGNORE + valueset_expansion_mode: PERFORM_NAIVE_EXPANSION # AUTO | USE_EXPANSION_OPERATION | PERFORM_NAIVE_EXPANSION + valueset_membership_mode: USE_EXPANSION # AUTO | USE_VALIDATE_CODE_OPERATION | USE_EXPANSION + code_lookup_mode: USE_CODESYSTEM_URL # AUTO | USE_VALIDATE_CODE_OPERATION | USE_CODESYSTEM_URL + data: + search_parameter_mode: USE_SEARCH_PARAMETERS # AUTO | USE_SEARCH_PARAMETERS | FILTER_IN_MEMORY + terminology_parameter_mode: FILTER_IN_MEMORY # AUTO | USE_VALUE_SET_URL | USE_INLINE_CODES | FILTER_IN_MEMORY + profile_mode: DECLARED # ENFORCED | DECLARED | OPTIONAL | TRUST | OFF cdshooks: enabled: true clientIdHeaderName: client_id - ### This enables the swagger-ui at /fhir/swagger-ui/index.html as well as the /fhir/api-docs (see https://hapifhir.io/hapi-fhir/docs/server_plain/openapi.html) - openapi_enabled: true - ### This is the FHIR version. Choose between, DSTU2, DSTU3, R4 or R5 - fhir_version: R4 - ### Flag is false by default. This flag enables runtime installation of IG's. - ig_runtime_upload_enabled: false - ### This flag when enabled to true, will avail evaluate measure operations from CR Module. - - ### enable to use the ApacheProxyAddressStrategy which uses X-Forwarded-* headers - ### to determine the FHIR server address - # use_apache_address_strategy: false - ### forces the use of the https:// protocol for the returned server address. - ### alternatively, it may be set using the X-Forwarded-Proto header. - # use_apache_address_strategy_https: false - ### enables the server to overwrite defaults on HTML, css, etc. under the url pattern of e.g. /content/custom ** - ### Folder with custom content MUST be named custom. If omitted then default content applies - #custom_content_path: ./custom - ### enables the server host custom content. If e.g. the value ./configs/app is supplied then the content - ### will be served under /web/app - #app_content_path: ./configs/app - ### enable to set the Server URL - # server_address: http://hapi.fhir.org/baseR4 - # defer_indexing_for_codesystems_of_size: 101 - ### Flag is true by default. This flag filters resources during package installation, allowing only those resources with a valid status (e.g. active) to be installed. - # validate_resource_status_for_package_upload: false - # install_transitive_ig_dependencies: true - #implementationguides: - ### example from registry (packages.fhir.org) - # swiss: - # name: swiss.mednet.fhir - # version: 0.8.0 - # reloadExisting: false - # installMode: STORE_AND_INSTALL - # example not from registry - # ips_1_0_0: - # packageUrl: https://build.fhir.org/ig/HL7/fhir-ips/package.tgz - # name: hl7.fhir.uv.ips - # version: 1.0.0 - # supported_resource_types: - # - Patient - # - Observation - ################################################## - # Allowed Bundle Types for persistence (defaults are: COLLECTION,DOCUMENT,MESSAGE) - ################################################## - # allowed_bundle_types: COLLECTION,DOCUMENT,MESSAGE,TRANSACTION,TRANSACTIONRESPONSE,BATCH,BATCHRESPONSE,HISTORY,SEARCHSET - # allow_cascading_deletes: true - # allow_contains_searches: true - allow_external_references: true - # allow_multiple_delete: true - # allow_override_default_search_params: true - # auto_create_placeholder_reference_targets: false - ### tells the server to automatically append the current version of the target resource to references at these paths - # auto_version_reference_at_paths: Device.patient, Device.location, Device.parent, DeviceMetric.parent, DeviceMetric.source, Observation.device, Observation.subject - # ips_enabled: false - # default_encoding: JSON - # default_pretty_print: true - # default_page_size: 20 - # delete_expunge_enabled: true - # enable_repository_validating_interceptor: true - # enable_index_missing_fields: false - # enable_index_of_type: true - # enable_index_contained_resource: false - # resource_dbhistory_enabled: false - ### !!Extended Lucene/Elasticsearch Indexing is still an experimental feature, expect some features (e.g. _total=accurate) to not work as expected!! - ### more information here: https://hapifhir.io/hapi-fhir/docs/server_jpa/elastic.html + # ------------------------------------------------------------------------------- + # D. Search & Indexing + # ------------------------------------------------------------------------------- + # 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 + # language_search_parameter_enabled: true + # upliftedRefchains_enabled: true + # index_storage_optimized: false + # enable_index_missing_fields: false + # enable_index_of_type: true + # enable_index_contained_resource: false + # store_resource_in_lucene_index_enabled: true + + # ------------------------------------------------------------------------------- + # E. Bulk Operations + # ------------------------------------------------------------------------------- bulk_export_enabled: false bulk_import_enabled: false - # language_search_parameter_enabled: true - # enforce_referential_integrity_on_delete: false - # This is an experimental feature, and does not fully support _total and other FHIR features. - # enforce_referential_integrity_on_delete: false - # enforce_referential_integrity_on_write: false - # etag_support_enabled: true - # expunge_enabled: true - # client_id_strategy: ALPHANUMERIC - # server_id_strategy: SEQUENTIAL_NUMERIC - # fhirpath_interceptor_enabled: false - # filter_search_enabled: true - # graphql_enabled: true + + # ------------------------------------------------------------------------------- + # F. Write / Delete / Integrity + # ------------------------------------------------------------------------------- + # allow_cascading_deletes: true + # allow_contains_searches: true + # allow_external_references: true + # allow_multiple_delete: true + # allow_override_default_search_params: true + # auto_create_placeholder_reference_targets: false + # mass_ingestion_mode_enabled: false + # auto_version_reference_at_paths: Device.patient, Device.location, Device.parent, DeviceMetric.parent, DeviceMetric.source, Observation.device, Observation.subject + # client_id_strategy: ALPHANUMERIC + # server_id_strategy: SEQUENTIAL_NUMERIC + # enforce_referential_integrity_on_delete: false + # enforce_referential_integrity_on_write: false + # etag_support_enabled: true + # expunge_enabled: true + # fhirpath_interceptor_enabled: false + # filter_search_enabled: true + # graphql_enabled: true + + # ------------------------------------------------------------------------------- + # G. Narrative & Validation + # ------------------------------------------------------------------------------- narrative_enabled: false + # validation: + # requests_enabled: true + # responses_enabled: true + + # ------------------------------------------------------------------------------- + # H. MDM (Master Data Management) + # ------------------------------------------------------------------------------- mdm_enabled: false mdm_rules_json_location: "mdm-rules.json" - # local_base_urls: - # - https://hapi.fhir.org/baseR4 - - # pre_expand_value_sets: true - # enable_task_pre_expand_value_sets: true - # pre_expand_value_sets_default_count: 1000 - # pre_expand_value_sets_max_count: 1000 - # maximum_expansion_size: 1000 + # userRequestRetryVersionConflictsInterceptorEnabled: false + # ------------------------------------------------------------------------------- + # I. Terminology / ValueSet Expansion + # ------------------------------------------------------------------------------- + # pre_expand_value_sets: true + # enable_task_pre_expand_value_sets: true + # pre_expand_value_sets_default_count: 1000 + # pre_expand_value_sets_max_count: 1000 + # maximum_expansion_size: 1000 logical_urls: - http://terminology.hl7.org/* - https://terminology.hl7.org/* @@ -244,96 +311,112 @@ hapi: - https://unitsofmeasure.org/* - http://loinc.org/* - https://loinc.org/* - # partitioning: - # allow_references_across_partitions: false - # partitioning_include_in_search_hashes: false - # conditional_create_duplicate_identifiers_enabled: false + + # ------------------------------------------------------------------------------- + # J. Partitioning & Multitenancy + # ------------------------------------------------------------------------------- + # partitioning: + # allow_references_across_partitions: false + # partitioning_include_in_search_hashes: false + # default_partition_id: 0 + # database_partition_mode_enabled: true + # patient_id_partitioning_mode: true + # request_tenant_partitioning_mode: false + + # ------------------------------------------------------------------------------- + # K. CORS + # ------------------------------------------------------------------------------- cors: allow_Credentials: true - # These are allowed_origin patterns, see: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/cors/CorsConfiguration.html#setAllowedOriginPatterns-java.util.List- allowed_origin: - - '*' + - "*" - # Search coordinator thread pool sizes + # ------------------------------------------------------------------------------- + # L. Search Orchestration + # ------------------------------------------------------------------------------- search-coord-core-pool-size: 20 search-coord-max-pool-size: 100 search-coord-queue-capacity: 200 - + # Search Prefetch Thresholds. + # This setting sets the number of search results to prefetch. For example, if this list + # is set to [100, 1000, -1] then the server will initially load 100 results and not + # attempt to load more. If the user requests subsequent page(s) of results and goes + # past 100 results, the system will load the next 900 (up to the following threshold of 1000). + # The system will progressively work through these thresholds. + # A threshold of -1 means to load all results. Note that if the final threshold is a + # number other than -1, the system will never prefetch more than the given number. + # CSV list; -1 as final value means "all" + search_prefetch_thresholds: 13,503,2003,-1 + + # ------------------------------------------------------------------------------- + # M. Extensibility (custom beans / interceptors / providers) + # ------------------------------------------------------------------------------- # comma-separated package names, will be @ComponentScan'ed by Spring to allow for creating custom Spring beans - #custom-bean-packages: - - # comma-separated list of fully qualified interceptor classes. - # classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages', - # or will be instantiated via reflection using a no-arg constructor; then registered with the server - #custom-interceptor-classes: - # comma-separated list of fully qualified provider classes. - # classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages', - # or will be instantiated via reflection using a no-arg constructor; then registered with the server - #custom-provider-classes: - - # Threadpool size for BATCH'ed GETs in a bundle. - # bundle_batch_pool_size: 10 - # bundle_batch_pool_max_size: 50 + # custom-provider-classes: + # custom-interceptor-classes: + # custom-provider-classes: - # logger: - # error_format: 'ERROR - ${requestVerb} ${requestUrl}' - # format: >- - # Path[${servletPath}] Source[${requestHeader.x-forwarded-for}] - # Operation[${operationType} ${operationName} ${idOrResourceName}] - # UA[${requestHeader.user-agent}] Params[${requestParameters}] - # ResponseEncoding[${responseEncodingNoDefault}] - # log_exceptions: true - # name: fhirtest.access - # max_binary_size: 104857600 - # max_page_size: 200 - # retain_cached_searches_mins: 60 - # reuse_cached_search_results_millis: 60000 - tester: - home: - name: Local Tester - server_address: 'http://localhost:8080/fhir' - refuse_to_fetch_third_party_urls: false - fhir_version: R4 - global: - name: Global Tester - server_address: "http://hapi.fhir.org/baseR4" - refuse_to_fetch_third_party_urls: false - fhir_version: R4 - # validation: - # requests_enabled: true - # responses_enabled: true - # binary_storage_enabled: true + # store_meta_source_information: NONE + # bundle_batch_pool_size: 10 + # bundle_batch_pool_max_size: 50 + + # ------------------------------------------------------------------------------- + # N. Logging + # ------------------------------------------------------------------------------- + # logger: + # error_format: "ERROR - ${requestVerb} ${requestUrl}" + # format: >- + # Path[${servletPath}] Source[${requestHeader.x-forwarded-for}] + # Operation[${operationType} ${operationName} ${idOrResourceName}] + # UA[${requestHeader.user-agent}] Params[${requestParameters}] + # ResponseEncoding[${responseEncodingNoDefault}] + # log_exceptions: true + # name: fhirtest.access + + # ------------------------------------------------------------------------------- + # O. Storage / Pagination / Caching + # ------------------------------------------------------------------------------- + # max_binary_size: 104857600 + # max_page_size: 200 + # retain_cached_searches_mins: 60 + # reuse_cached_search_results_millis: 60000 inline_resource_storage_below_size: 4000 -# bulk_export_enabled: true -# subscription: -# resthook_enabled: true -# websocket_enabled: false -# email: -# from: some@test.com -# host: google.com -# port: -# username: -# password: -# auth: -# startTlsEnable: -# startTlsRequired: -# quitWait: -# lastn_enabled: true -# store_resource_in_lucene_index_enabled: true -### This is configuration for normalized quantity search level default is 0 -### 0: NORMALIZED_QUANTITY_SEARCH_NOT_SUPPORTED - default -### 1: NORMALIZED_QUANTITY_STORAGE_SUPPORTED -### 2: NORMALIZED_QUANTITY_SEARCH_SUPPORTED -# normalized_quantity_search_level: 2 -#elasticsearch: -# debug: -# pretty_print_json_log: false -# refresh_after_write: false -# enabled: false -# password: SomePassword -# required_index_status: YELLOW -# rest_url: 'localhost:9200' -# protocol: 'http' -# schema_management_strategy: CREATE -# username: SomeUsername + + # ------------------------------------------------------------------------------- + # P. Remote Terminology Service (disabled by default) + # ------------------------------------------------------------------------------- + # remote_terminology_service: + # all: + # system: "*" + # url: "https://tx.fhir.org/r4/" + # snomed: + # system: "http://snomed.info/sct" + # url: "https://tx.fhir.org/r4/" + # loinc: + # system: "http://loinc.org" + # url: "https://hapi.fhir.org/baseR4/" + + # ------------------------------------------------------------------------------- + # Q. Subscriptions (disabled by default) + # ------------------------------------------------------------------------------- + # subscription: + # resthook_enabled: true + # websocket_enabled: false + # polling_interval_ms: 5000 + # immediately_queued: false + # email: + # from: some@test.com + # host: google.com + # port: + # username: + # password: + # auth: + # startTlsEnable: + # startTlsRequired: + # quitWait: + + # ------------------------------------------------------------------------------- + # R. LastN (analytics) + # ------------------------------------------------------------------------------- + # lastn_enabled: true From a5a011f4cfd142815772bf4de0d7e9c68731d272 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Thu, 2 Oct 2025 10:17:58 -0600 Subject: [PATCH 15/20] Add Using CQL IG --- src/main/resources/cds.application.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/resources/cds.application.yaml b/src/main/resources/cds.application.yaml index 82f2f8a..da76591 100644 --- a/src/main/resources/cds.application.yaml +++ b/src/main/resources/cds.application.yaml @@ -164,7 +164,12 @@ hapi: ig_runtime_upload_enabled: false # validate_resource_status_for_package_upload: false # default true # install_transitive_ig_dependencies: true - # implementationguides: + implementationguides: + cql: + name: hl7.fhir.uv.cql + version: 2.0.0 + installMode: STORE_AND_INSTALL + installResourceTypes: [ "Library" ] # swiss: # name: swiss.mednet.fhir # version: 0.8.0 From 9e8ba349a7989e575d80df0e12f777b9819a6d55 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Thu, 2 Oct 2025 10:21:51 -0600 Subject: [PATCH 16/20] allow external references --- src/main/resources/cds.application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/cds.application.yaml b/src/main/resources/cds.application.yaml index da76591..3224316 100644 --- a/src/main/resources/cds.application.yaml +++ b/src/main/resources/cds.application.yaml @@ -268,7 +268,7 @@ hapi: # ------------------------------------------------------------------------------- # allow_cascading_deletes: true # allow_contains_searches: true - # allow_external_references: true + allow_external_references: true # allow_multiple_delete: true # allow_override_default_search_params: true # auto_create_placeholder_reference_targets: false From 3c7cddcb35dab6757992edbd8d4704ff28c95363 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Thu, 2 Oct 2025 10:26:54 -0600 Subject: [PATCH 17/20] Add all expected resource types to be installed from an IG --- src/main/resources/cds.application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/cds.application.yaml b/src/main/resources/cds.application.yaml index 3224316..a11eceb 100644 --- a/src/main/resources/cds.application.yaml +++ b/src/main/resources/cds.application.yaml @@ -169,7 +169,7 @@ hapi: name: hl7.fhir.uv.cql version: 2.0.0 installMode: STORE_AND_INSTALL - installResourceTypes: [ "Library" ] + installResourceTypes: [ "CodeSystem", "ValueSet", "ConceptMap", "ActivityDefinition", "PlanDefinition", "Library", "Measure", "StructureDefinition" ] # swiss: # name: swiss.mednet.fhir # version: 0.8.0 From 8bd5a91454d698ee0f1e285aaa99e51b4c5f47b0 Mon Sep 17 00:00:00 2001 From: Brenin Rhodes <brenin.rhodes@smiledigitalhealth.com> Date: Thu, 2 Oct 2025 13:07:28 -0600 Subject: [PATCH 18/20] rename cds application yaml --- README.md | 2 +- .../resources/{cds.application.yaml => application-cds.yaml} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/main/resources/{cds.application.yaml => application-cds.yaml} (100%) diff --git a/README.md b/README.md index 3a0cef4..5c2e220 100644 --- a/README.md +++ b/README.md @@ -495,7 +495,7 @@ The server may be configured with subscription support by enabling properties in ## Enabling Clinical Reasoning -Set `hapi.fhir.cr.enabled=true` in the [application.yaml](https://github.com/hapifhir/hapi-fhir-jpaserver-starter/blob/master/src/main/resources/application.yaml) file to enable [Clinical Quality Language](https://cql.hl7.org/) on this server. An alternate settings file, [cds.application.yaml](https://github.com/hapifhir/hapi-fhir-jpaserver-starter/blob/master/src/main/resources/cds.application.yaml), exists with the Clinical Reasoning module enabled and default settings that have been found to work with most CDS and dQM test cases. +Set `hapi.fhir.cr.enabled=true` in the [application.yaml](https://github.com/hapifhir/hapi-fhir-jpaserver-starter/blob/master/src/main/resources/application.yaml) file to enable [Clinical Quality Language](https://cql.hl7.org/) on this server. An alternate settings file, [application-cds.yaml](https://github.com/hapifhir/hapi-fhir-jpaserver-starter/blob/master/src/main/resources/application-cds.yaml), exists with the Clinical Reasoning module enabled and default settings that have been found to work with most CDS and dQM test cases. ## Enabling CDS Hooks diff --git a/src/main/resources/cds.application.yaml b/src/main/resources/application-cds.yaml similarity index 100% rename from src/main/resources/cds.application.yaml rename to src/main/resources/application-cds.yaml From a55c8f20a9cda619a9e407dc7ad0000ca42ef4d7 Mon Sep 17 00:00:00 2001 From: Patrick Werner <pa.f.werner@gmail.com> Date: Thu, 9 Oct 2025 14:15:17 +0200 Subject: [PATCH 19/20] make thread count configurable (#868) * feat: add configuration for reindex and expunge thread counts * fix formatting --- .../ca/uhn/fhir/jpa/starter/AppProperties.java | 18 ++++++++++++++++++ .../starter/common/FhirServerConfigCommon.java | 15 +++++++++++++++ src/main/resources/application.yaml | 5 +++++ 3 files changed, 38 insertions(+) diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index f10f693..ed32f51 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -118,6 +118,8 @@ public class AppProperties { private Map<String, RemoteSystem> remote_terminology_service = null; private Boolean match_url_cache_enabled = false; private Boolean index_storage_optimized = false; + private Integer reindex_thread_count = null; + private Integer expunge_thread_count = null; public List<String> getCustomInterceptorClasses() { return custom_interceptor_classes; @@ -785,6 +787,22 @@ public class AppProperties { index_storage_optimized = theIndex_storage_optimized; } + public Integer getReindex_thread_count() { + return reindex_thread_count; + } + + public void setReindex_thread_count(Integer reindex_thread_count) { + this.reindex_thread_count = reindex_thread_count; + } + + public Integer getExpunge_thread_count() { + return expunge_thread_count; + } + + public void setExpunge_thread_count(Integer expunge_thread_count) { + this.expunge_thread_count = expunge_thread_count; + } + public JpaStorageSettings.StoreMetaSourceInformationEnum getStore_meta_source_information() { return store_meta_source_information; } 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 f28a2c6..6ca4834 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 @@ -282,6 +282,21 @@ public class FhirServerConfigCommon { jpaStorageSettings.setIndexOnContainedResources(appProperties.getEnable_index_contained_resource()); jpaStorageSettings.setIndexIdentifierOfType(appProperties.getEnable_index_of_type()); + + // Configure thread counts for reindex and expunge operations + if (appProperties.getReindex_thread_count() != null) { + jpaStorageSettings.setReindexThreadCount(appProperties.getReindex_thread_count()); + ourLog.info( + "Server configured to use {} threads for reindex operations", + appProperties.getReindex_thread_count()); + } + if (appProperties.getExpunge_thread_count() != null) { + jpaStorageSettings.setExpungeThreadCount(appProperties.getExpunge_thread_count()); + ourLog.info( + "Server configured to use {} threads for expunge operations", + appProperties.getExpunge_thread_count()); + } + return jpaStorageSettings; } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 967ba37..af9fdcb 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -279,6 +279,11 @@ hapi: # filter_search_enabled: true # graphql_enabled: true + # Thread pool configuration for maintenance operations + # Defaults to Runtime.getRuntime().availableProcessors() if not specified + # reindex_thread_count: 4 # Number of threads to use for reindex operations + # expunge_thread_count: 4 # Number of threads to use for expunge operations + # ------------------------------------------------------------------------------- # G. Narrative & Validation # ------------------------------------------------------------------------------- From cf003331e4d30e23065022fa771fad828e5d958b Mon Sep 17 00:00:00 2001 From: Shamus Husheer <shusheer@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:39:01 -0400 Subject: [PATCH 20/20] Add configurable filesystem binary storage (fix #860) (#864) * Add configurable filesystem binary storage (fix #860) * Refine binary storage configuration handling * Refine binary storage bean wiring * Refine binary storage conditional beans * Add integration coverage for binary storage configs * Exercise binary storage via REST integration tests * Update src/main/resources/application.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Ubuntu <ubuntu@ip-172-31-35-43.eu-west-2.compute.internal> Co-authored-by: Ubuntu <ubuntu@ip-172-31-10-131.eu-west-2.compute.internal> Co-authored-by: Jens Kristian Villadsen <jenskristianvilladsen@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 15 + .../uhn/fhir/jpa/starter/AppProperties.java | 26 +- .../common/FhirServerConfigCommon.java | 56 ++- src/main/resources/application.yaml | 8 + .../starter/BinaryStorageIntegrationTest.java | 324 ++++++++++++++++++ ...irServerConfigCommonBinaryStorageTest.java | 96 ++++++ .../resources/binary-storage-test-empty.yaml | 1 + 7 files changed, 516 insertions(+), 10 deletions(-) create mode 100644 src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java create mode 100644 src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java create mode 100644 src/test/resources/binary-storage-test-empty.yaml diff --git a/README.md b/README.md index 5c2e220..96b11b8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,21 @@ docker run -p 8080:8080 -e hapi.fhir.default_encoding=xml hapiproject/hapi:lates HAPI looks in the environment variables for properties in the [application.yaml](https://github.com/hapifhir/hapi-fhir-jpaserver-starter/blob/master/src/main/resources/application.yaml) file for defaults. +### Binary storage configuration + +To stream large `Binary` payloads to disk instead of the database, configure the starter with filesystem storage properties: + +``` +hapi: + fhir: + binary_storage_enabled: true + binary_storage_mode: FILESYSTEM + binary_storage_filesystem_base_directory: /binstore + # inline_resource_storage_below_size: 131072 # optional override +``` + +When `binary_storage_mode` is set to `FILESYSTEM` and `inline_resource_storage_below_size` is omitted, the starter automatically applies a 102400 byte (100 KB) inline threshold so smaller payloads remain in the database. Ensure the directory you point to is writable by the process (for Docker builds, mount it into the container with appropriate permissions). + ### Configuration via overridden application.yaml file and using Docker You can customize HAPI by telling HAPI to look for the configuration file in a different location, e.g.: 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 ed32f51..94f12dd 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -61,7 +61,15 @@ public class AppProperties { private Boolean filter_search_enabled = true; private Boolean graphql_enabled = false; private Boolean binary_storage_enabled = false; - private Integer inline_resource_storage_below_size = 0; + + public enum BinaryStorageMode { + DATABASE, + FILESYSTEM + } + + private BinaryStorageMode binary_storage_mode = BinaryStorageMode.DATABASE; + private String binary_storage_filesystem_base_directory; + private Integer inline_resource_storage_below_size; private Boolean bulk_export_enabled = false; private Boolean bulk_import_enabled = false; private Boolean default_pretty_print = true; @@ -485,6 +493,22 @@ public class AppProperties { this.binary_storage_enabled = binary_storage_enabled; } + public BinaryStorageMode getBinary_storage_mode() { + return binary_storage_mode; + } + + public void setBinary_storage_mode(BinaryStorageMode binary_storage_mode) { + this.binary_storage_mode = binary_storage_mode; + } + + public String getBinary_storage_filesystem_base_directory() { + return binary_storage_filesystem_base_directory; + } + + public void setBinary_storage_filesystem_base_directory(String binary_storage_filesystem_base_directory) { + this.binary_storage_filesystem_base_directory = binary_storage_filesystem_base_directory; + } + public Integer getInline_resource_storage_below_size() { return inline_resource_storage_below_size; } 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 6ca4834..da3ce06 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 @@ -1,8 +1,8 @@ package ca.uhn.fhir.jpa.starter.common; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; -import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc; import ca.uhn.fhir.jpa.binstore.DatabaseBinaryContentStorageSvcImpl; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; import ca.uhn.fhir.jpa.config.HibernatePropertiesProvider; import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.model.config.PartitionSettings.CrossPartitionReferenceMode; @@ -19,10 +19,12 @@ import com.google.common.base.Strings; import org.hl7.fhir.r4.model.Bundle.BundleType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.env.YamlPropertySourceLoader; import org.springframework.context.annotation.*; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.util.Assert; import java.util.HashSet; import java.util.stream.Collectors; @@ -38,6 +40,7 @@ import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; public class FhirServerConfigCommon { private static final Logger ourLog = LoggerFactory.getLogger(FhirServerConfigCommon.class); + private static final int DEFAULT_FILESYSTEM_INLINE_THRESHOLD = 102_400; public FhirServerConfigCommon(AppProperties appProperties) { ourLog.info( @@ -222,8 +225,9 @@ public class FhirServerConfigCommon { jpaStorageSettings.setLastNEnabled(true); } - if (appProperties.getInline_resource_storage_below_size() != 0) { - jpaStorageSettings.setInlineResourceTextBelowSize(appProperties.getInline_resource_storage_below_size()); + Integer inlineResourceThreshold = resolveInlineResourceThreshold(appProperties); + if (inlineResourceThreshold != null && inlineResourceThreshold != 0) { + jpaStorageSettings.setInlineResourceTextBelowSize(inlineResourceThreshold); } jpaStorageSettings.setStoreResourceInHSearchIndex(appProperties.getStore_resource_in_lucene_index_enabled()); @@ -354,16 +358,50 @@ public class FhirServerConfigCommon { return new JpaHibernatePropertiesProvider(myEntityManagerFactory); } - @Lazy @Bean - public IBinaryStorageSvc binaryStorageSvc(AppProperties appProperties) { - DatabaseBinaryContentStorageSvcImpl binaryStorageSvc = new DatabaseBinaryContentStorageSvcImpl(); + @ConditionalOnProperty(prefix = "hapi.fhir", name = "binary_storage_mode", havingValue = "FILESYSTEM") + public FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc(AppProperties appProperties) { + String baseDirectory = appProperties.getBinary_storage_filesystem_base_directory(); + Assert.hasText( + baseDirectory, + "binary_storage_filesystem_base_directory must be provided when binary_storage_mode=FILESYSTEM"); - if (appProperties.getMax_binary_size() != null) { - binaryStorageSvc.setMaximumBinarySize(appProperties.getMax_binary_size()); + FilesystemBinaryStorageSvcImpl filesystemSvc = new FilesystemBinaryStorageSvcImpl(baseDirectory); + Integer inlineResourceThreshold = resolveInlineResourceThreshold(appProperties); + int minimumBinarySize = + inlineResourceThreshold == null ? DEFAULT_FILESYSTEM_INLINE_THRESHOLD : inlineResourceThreshold; + filesystemSvc.setMinimumBinarySize(minimumBinarySize); + + Integer maxBinarySize = appProperties.getMax_binary_size(); + if (maxBinarySize != null) { + filesystemSvc.setMaximumBinarySize(maxBinarySize.longValue()); } - return binaryStorageSvc; + return filesystemSvc; + } + + @Bean + @ConditionalOnProperty( + prefix = "hapi.fhir", + name = "binary_storage_mode", + havingValue = "DATABASE", + matchIfMissing = true) + public DatabaseBinaryContentStorageSvcImpl databaseBinaryStorageSvc(AppProperties appProperties) { + DatabaseBinaryContentStorageSvcImpl databaseSvc = new DatabaseBinaryContentStorageSvcImpl(); + Integer maxBinarySize = appProperties.getMax_binary_size(); + if (maxBinarySize != null) { + databaseSvc.setMaximumBinarySize(maxBinarySize.longValue()); + } + return databaseSvc; + } + + private Integer resolveInlineResourceThreshold(AppProperties appProperties) { + Integer inlineResourceThreshold = appProperties.getInline_resource_storage_below_size(); + if (inlineResourceThreshold == null + && appProperties.getBinary_storage_mode() == AppProperties.BinaryStorageMode.FILESYSTEM) { + return DEFAULT_FILESYSTEM_INLINE_THRESHOLD; + } + return inlineResourceThreshold; } @Bean diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index af9fdcb..5dff60e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -386,6 +386,14 @@ hapi: # max_page_size: 200 # retain_cached_searches_mins: 60 # reuse_cached_search_results_millis: 60000 + # validation: + # requests_enabled: true + # responses_enabled: true + # binary_storage_enabled: true + # binary_storage_mode: FILESYSTEM + # binary_storage_filesystem_base_directory: /binstore + # When binary_storage_mode is FILESYSTEM and this value is not set, + # the starter defaults to 102400 bytes so smaller binaries stay inline. inline_resource_storage_below_size: 4000 # ------------------------------------------------------------------------------- diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java new file mode 100644 index 0000000..f40368e --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java @@ -0,0 +1,324 @@ +package ca.uhn.fhir.jpa.starter; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; +import ca.uhn.fhir.jpa.dao.data.IBinaryStorageEntityDao; +import ca.uhn.fhir.jpa.model.entity.BinaryStorageEntity; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +abstract class BaseBinaryStorageIntegrationTest { + protected static final String COMMON_CONFIG_LOCATION = "spring.config.location=classpath:/binary-storage-test-empty.yaml"; + protected static final String COMMON_H2_USERNAME = "spring.datasource.username=sa"; + protected static final String COMMON_H2_PASSWORD = "spring.datasource.password="; + protected static final String COMMON_JPA_DDL = "spring.jpa.hibernate.ddl-auto=create-drop"; + protected static final String COMMON_HIBERNATE_DIALECT = + "spring.jpa.properties.hibernate.dialect=ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect"; + protected static final String COMMON_HIBERNATE_SEARCH_DISABLED = "spring.jpa.properties.hibernate.search.enabled=false"; + protected static final String COMMON_FLYWAY_DISABLED = "spring.flyway.enabled=false"; + protected static final String COMMON_FHIR_VERSION = "hapi.fhir.fhir_version=r4"; + protected static final String COMMON_REPO_VALIDATION_DISABLED = + "hapi.fhir.enable_repository_validating_interceptor=false"; + protected static final String COMMON_MDM_DISABLED = "hapi.fhir.mdm_enabled=false"; + protected static final String COMMON_CR_DISABLED = "hapi.fhir.cr_enabled=false"; + protected static final String COMMON_SUBSCRIPTION_WS_DISABLED = "hapi.fhir.subscription.websocket_enabled=false"; + protected static final String COMMON_BEAN_OVERRIDE_ALLOWED = "spring.main.allow-bean-definition-overriding=true"; + protected static final String COMMON_CIRCULAR_REFERENCES = "spring.main.allow-circular-references=true"; + protected static final String COMMON_MCP_DISABLED = "spring.ai.mcp.server.enabled=false"; + protected static final String CONTENT_TYPE = "application/octet-stream"; + + @LocalServerPort + protected int port; + + protected FhirContext fhirContext; + protected IGenericClient client; + private final List<IIdType> resourcesToDelete = new ArrayList<>(); + + @BeforeEach + void setUpClient() { + fhirContext = FhirContext.forR4(); + fhirContext.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER); + fhirContext.getRestfulClientFactory().setSocketTimeout(1200 * 1000); + String serverBase = "http://localhost:" + port + "/fhir/"; + client = fhirContext.newRestfulGenericClient(serverBase); + resourcesToDelete.clear(); + } + + @AfterEach + void deleteCreatedResources() { + for (IIdType id : resourcesToDelete) { + try { + client.delete().resourceById(id).execute(); + } catch (Exception ignored) { + // Ignore cleanup failures to keep tests resilient + } + } + } + + protected IIdType createPatientWithPhoto(String label, byte[] payload) { + Patient patient = new Patient(); + patient.addIdentifier().setSystem("urn:binary-storage-test").setValue(label); + patient.addName().setFamily(label); + patient.addPhoto().setContentType(CONTENT_TYPE).setData(payload); + IIdType id = client.create().resource(patient).execute().getId().toUnqualifiedVersionless(); + resourcesToDelete.add(id); + return id; + } + + protected String uniqueLabel(String prefix) { + return prefix + "-" + UUID.randomUUID(); + } + + protected byte[] randomBytes(int size) { + byte[] payload = new byte[size]; + ThreadLocalRandom.current().nextBytes(payload); + return payload; + } + + protected void assertRegularFileCount(Path baseDir, long expectedFileCount) throws IOException { + assertThat(regularFileCount(baseDir)).isEqualTo(expectedFileCount); + } + + protected void assertRegularFileCountGreaterThan(Path baseDir, long minimumFileCount) throws IOException { + assertThat(regularFileCount(baseDir)).isGreaterThan(minimumFileCount); + } + + protected Path ensureDirectory(Path directory) throws IOException { + Files.createDirectories(directory); + return directory; + } + + protected void deleteDirectoryContents(Path baseDir) throws IOException { + if (Files.notExists(baseDir)) { + return; + } + try (Stream<Path> files = Files.walk(baseDir)) { + files.sorted(Comparator.reverseOrder()) + .filter(path -> !path.equals(baseDir)) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + private long regularFileCount(Path baseDir) throws IOException { + if (Files.notExists(baseDir)) { + return 0; + } + try (Stream<Path> files = Files.walk(baseDir)) { + return files.filter(Files::isRegularFile).count(); + } + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-db;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "hapi.fhir.binary_storage_enabled=true", + "hapi.fhir.binary_storage_mode=DATABASE" + } +) +class BinaryStorageDatabaseModeIT extends BaseBinaryStorageIntegrationTest { + + @Autowired + private IBinaryStorageEntityDao binaryStorageEntityDao; + + @Autowired + private PlatformTransactionManager transactionManager; + + private TransactionTemplate transactionTemplate; + + @BeforeEach + void initTemplate() { + transactionTemplate = new TransactionTemplate(transactionManager); + } + + @Test + void largeAttachmentStoredInDatabase() { + Set<String> beforeIds = captureContentIds(); + + createPatientWithPhoto(uniqueLabel("database"), randomBytes(150_000)); + + Set<String> afterIds = captureContentIds(); + afterIds.removeAll(beforeIds); + assertThat(afterIds).hasSize(1); + + String binaryId = afterIds.iterator().next(); + BinaryStorageEntity entity = transactionTemplate.execute(status -> + binaryStorageEntityDao.findById(binaryId).orElseThrow()); + + assertThat(entity.hasStorageContent()).isTrue(); + assertThat(entity.getStorageContentBin()).hasSize(150_000); + + transactionTemplate.execute(status -> { + binaryStorageEntityDao.deleteById(binaryId); + return null; + }); + } + + private Set<String> captureContentIds() { + return transactionTemplate.execute(status -> + binaryStorageEntityDao.findAll().stream() + .map(BinaryStorageEntity::getContentId) + .collect(Collectors.toCollection(LinkedHashSet::new))); + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-fs-default;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "hapi.fhir.binary_storage_enabled=true", + "hapi.fhir.binary_storage_mode=FILESYSTEM", + "hapi.fhir.binary_storage_filesystem_base_directory=target/test-binary-storage/filesystem-default" + } +) +class BinaryStorageFilesystemDefaultIT extends BaseBinaryStorageIntegrationTest { + static final Path BASE_DIRECTORY = Paths.get("target/test-binary-storage/filesystem-default").toAbsolutePath(); + + @Autowired + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc; + + @BeforeEach + void prepareDirectory() throws IOException { + ensureDirectory(BASE_DIRECTORY); + deleteDirectoryContents(BASE_DIRECTORY); + } + + @Test + void filesystemModeUsesDefaultThreshold() throws IOException { + assertThat(filesystemBinaryStorageSvc.getMinimumBinarySize()).isEqualTo(102_400); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-default-inline"), randomBytes(50_000)); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-default-offload"), randomBytes(150_000)); + assertRegularFileCountGreaterThan(BASE_DIRECTORY, 0); + } + + @AfterEach + void cleanUpDirectory() throws IOException { + deleteDirectoryContents(BASE_DIRECTORY); + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-fs-custom;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "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" + } +) +class BinaryStorageFilesystemCustomThresholdIT extends BaseBinaryStorageIntegrationTest { + static final Path BASE_DIRECTORY = Paths.get("target/test-binary-storage/filesystem-custom").toAbsolutePath(); + + @Autowired + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc; + + @BeforeEach + void prepareDirectory() throws IOException { + ensureDirectory(BASE_DIRECTORY); + deleteDirectoryContents(BASE_DIRECTORY); + } + + @Test + void filesystemModeHonoursCustomThreshold() throws IOException { + assertThat(filesystemBinaryStorageSvc.getMinimumBinarySize()).isEqualTo(32_768); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-custom-inline"), randomBytes(30_000)); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-custom-offload"), randomBytes(40_000)); + assertRegularFileCountGreaterThan(BASE_DIRECTORY, 0); + } + + @AfterEach + void cleanUpDirectory() throws IOException { + deleteDirectoryContents(BASE_DIRECTORY); + } +} 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 new file mode 100644 index 0000000..bad6417 --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java @@ -0,0 +1,96 @@ +package ca.uhn.fhir.jpa.starter.common; + +import ca.uhn.fhir.jpa.binstore.DatabaseBinaryContentStorageSvcImpl; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; +import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc; +import ca.uhn.fhir.jpa.starter.AppProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FhirServerConfigCommonBinaryStorageTest { + + @TempDir + Path tempDir; + + private FhirServerConfigCommon newConfig() { + return new FhirServerConfigCommon(new AppProperties()); + } + + @Test + void defaultsToDatabaseImplementation() { + AppProperties props = new AppProperties(); + + IBinaryStorageSvc svc = binaryStorageSvc(props); + + assertThat(svc).isInstanceOf(DatabaseBinaryContentStorageSvcImpl.class); + } + + @Test + void filesystemModeUsesDefaultMinimumWhenUnspecified() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + Path baseDir = tempDir.resolve("fs-default"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isEqualTo(102_400); + } + + @Test + void filesystemModeHonoursExplicitMinimum() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + props.setInline_resource_storage_below_size(4096); + Path baseDir = tempDir.resolve("fs-min-explicit"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isEqualTo(4096); + } + + @Test + void filesystemModeSupportsZeroMinimumWhenExplicit() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + props.setInline_resource_storage_below_size(0); + Path baseDir = tempDir.resolve("fs-zero"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isZero(); + } + + @Test + void filesystemModeRequiresBaseDirectory() { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + + assertThatThrownBy(() -> filesystemBinaryStorageSvc(props)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("binary_storage_filesystem_base_directory"); + } + + private IBinaryStorageSvc binaryStorageSvc(AppProperties props) { + FhirServerConfigCommon config = newConfig(); + if (props.getBinary_storage_mode() == AppProperties.BinaryStorageMode.FILESYSTEM) { + return config.filesystemBinaryStorageSvc(props); + } + return config.databaseBinaryStorageSvc(props); + } + + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc(AppProperties props) { + return (FilesystemBinaryStorageSvcImpl) binaryStorageSvc(props); + } +} diff --git a/src/test/resources/binary-storage-test-empty.yaml b/src/test/resources/binary-storage-test-empty.yaml new file mode 100644 index 0000000..d889d02 --- /dev/null +++ b/src/test/resources/binary-storage-test-empty.yaml @@ -0,0 +1 @@ +# Empty config to bypass default application.yaml during BinaryStorageIntegrationTest