diff --git a/cqf-fhir-cr-dev-server/README.md b/cqf-fhir-cr-dev-server/README.md new file mode 100644 index 0000000000..c4e1fe63a2 --- /dev/null +++ b/cqf-fhir-cr-dev-server/README.md @@ -0,0 +1,38 @@ +# cqf-fhir-cr-dev-server + +A self-contained CQF Clinical Reasoning FHIR development server. Wraps HAPI's `RestfulServer` +on Spring Boot's embedded Tomcat, registers the operation providers from `cqf-fhir-cr-hapi` +(`$evaluate-measure`, `$apply`, etc.), and bridges plain CRUD to an `IRepository`. The +default build wires an in-memory repository — swap it out for a JPA, REST, or IG-backed one +in `ServerR4Config` when integrating elsewhere. + +## Configuration + +Spring Boot config under `application.yml`: + +```yaml +server: + port: 8080 # HTTP port + +cqf: + server: + base-path: /fhir # servlet mount path + fhir-version: R4 # R4 (DSTU3 not yet wired) +``` + +Override on the command line: `--server.port=9090 --cqf.server.base-path=/r4`. + +## Run from source + +```bash +# Build the fat JAR +./gradlew :cqf-fhir-cr-dev-server:bootJar + +# Run it +java -jar cqf-fhir-cr-dev-server/build/libs/cqf-fhir-cr-dev-server-*.jar + +# Smoke test +curl http://localhost:8080/fhir/metadata +``` + +`./gradlew :cqf-fhir-cr-dev-server:bootRun` works too for live-reload development. diff --git a/cqf-fhir-cr-dev-server/build.gradle.kts b/cqf-fhir-cr-dev-server/build.gradle.kts new file mode 100644 index 0000000000..1e4c2b3725 --- /dev/null +++ b/cqf-fhir-cr-dev-server/build.gradle.kts @@ -0,0 +1,78 @@ +import org.springframework.boot.gradle.tasks.bundling.BootBuildImage + +plugins { + id("cqf.java-conventions") + id("cqf.spotless-conventions") + id("cqf.jacoco-conventions") + application + alias(libs.plugins.spring.boot) +} + +application { mainClass = "org.opencds.cqf.fhir.cr.server.Application" } + +tasks.named("bootJar") { + mainClass = "org.opencds.cqf.fhir.cr.server.Application" + archiveClassifier = "" +} + +// Disable the thin "plain" JAR so the bootJar fat JAR is the primary artifact. +tasks.named("jar") { enabled = false } + +dependencies { + // cqf-fhir-cr-hapi pulls hapi-fhir-jpaserver-base + hapi-fhir-server-cds-hooks via `api`. + // Both bring substantial transitive trees (Hibernate Search → Lucene/Elasticsearch, JDBC + // drivers, CDS-Hooks framework) we never initialize at runtime in this server. + // + // The exclusion list below is the *known-safe* subset: it removes the modules that contain + // no classes touched at runtime, verified by integration tests + live curl smoke. More + // aggressive exclusions (Hibernate ORM, Hibernate Search, Spring Data, FHIR version model + // jars) caused HAPI's reflective `ValidationSupportChain` construction (used by the + // FHIRPath engine inside ResourceMatcher) to fail with HAPI-2330 on string searches. + // Resolving that requires either upstream changes in cqf-fhir-cr-hapi to make those deps + // optional, or wiring a non-default ValidationSupport so the reflective fallback is skipped. + // + // Result: 311 MB -> ~210 MB (-32%). Phase-2 split of cqf-fhir-cr-hapi gets us the rest. + api(project(":cqf-fhir-cr-hapi")) { + // Trim only dependencies with no reflective entry point in HAPI's validator/FHIRPath + // construction. Wider exclusions (the JPA modules, Hibernate Search, FHIR-version + // model jars) broke ValidationSupportChain reflective construction (HAPI-2330) which + // ResourceMatcher needs for string-typed search params. + exclude(group = "ca.uhn.hapi.fhir", module = "hapi-fhir-server-cds-hooks") + exclude(group = "org.xerial", module = "sqlite-jdbc") + exclude(group = "com.oracle.database.jdbc", module = "ojdbc11") + exclude(group = "com.h2database", module = "h2") + exclude(group = "org.postgresql", module = "postgresql") + exclude(group = "com.microsoft.sqlserver", module = "mssql-jdbc") + exclude(group = "net.sourceforge.plantuml", module = "plantuml-mit") + exclude(group = "org.apache.jena") + exclude(group = "co.elastic.clients") + exclude(group = "org.elasticsearch") + exclude(group = "org.elasticsearch.client") + // hapi-fhir-storage-cr brings the CDS-Hooks server pieces; CDS hook flow isn't wired. + exclude(group = "ca.uhn.hapi.fhir", module = "hapi-fhir-storage-cr") + + // Hibernate ORM core. (Hibernate Search stays — its absence broke ValidationSupportChain + // reflection.) + exclude(group = "org.hibernate.orm", module = "hibernate-core") + exclude(group = "org.hibernate.orm", module = "hibernate-envers") + + // The hapi-fhir-jpaserver-* modules + hapi-fhir-jpa stay despite never being + // instantiated. Excluding any subset breaks HAPI's reflective ValidationSupportChain + // construction (used by ResourceMatcher's FHIRPath engine for string searches and + // operation processors). Need an upstream split before they can come out. + } + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.autoconfigure) + compileOnly(libs.jakarta.servlet.api) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.hapi.fhir.test.utilities) + testImplementation(project(":cqf-fhir-test")) +} + +// OCI image via Spring Boot's buildpacks (no Dockerfile required). +// Run: ./gradlew :cqf-fhir-cr-dev-server:bootBuildImage +tasks.named("bootBuildImage") { + imageName.set("cqf-fhir-cr-dev-server:${project.version}") + environment.put("BP_JVM_VERSION", "17") +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/Application.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/Application.java new file mode 100644 index 0000000000..b1e3d65042 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/Application.java @@ -0,0 +1,41 @@ +package org.opencds.cqf.fhir.cr.dev.server; + +import org.opencds.cqf.fhir.cr.dev.server.config.ServerR4Config; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration; +import org.springframework.context.annotation.Import; + +/** + * CQF Clinical Reasoning server. Mounts HAPI's {@code RestfulServer} on Spring Boot's embedded + * Tomcat, registers operation providers from {@code cqf-fhir-cr-hapi}, and bridges plain CRUD + * to an in-memory {@code IRepository}. + * + *

Spring Boot's JPA / DataSource / JDBC auto-configurations are explicitly excluded — the + * server has no datasource, and Hibernate must stay dormant on the classpath even though it's + * pulled transitively from {@code hapi-fhir-jpaserver-base}. See spike measurements: with these + * exclusions, no Hibernate code initializes during startup. + */ +@SpringBootApplication( + exclude = { + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + QuartzAutoConfiguration.class + }) +@Import(ServerR4Config.class) +public class Application { + + public static void main(String[] args) { + new Application().run(args); + } + + void run(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositoryResourceProvider.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositoryResourceProvider.java new file mode 100644 index 0000000000..4ae1ef8da0 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositoryResourceProvider.java @@ -0,0 +1,153 @@ +package org.opencds.cqf.fhir.cr.dev.server; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.annotation.ConditionalUrlParam; +import ca.uhn.fhir.rest.annotation.Create; +import ca.uhn.fhir.rest.annotation.Delete; +import ca.uhn.fhir.rest.annotation.IdParam; +import ca.uhn.fhir.rest.annotation.Read; +import ca.uhn.fhir.rest.annotation.ResourceParam; +import ca.uhn.fhir.rest.annotation.Search; +import ca.uhn.fhir.rest.annotation.Update; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.api.server.IRepositoryFactory; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.IResourceProvider; +import ca.uhn.fhir.rest.server.SimpleBundleProvider; +import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; +import ca.uhn.fhir.util.BundleUtil; +import java.util.List; +import java.util.Map; +import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.opencds.cqf.fhir.cr.dev.server.search.SearchParameterTranslator; + +/** + * Bridges HAPI {@code RestfulServer}'s typed CRUD interactions to an {@code IRepository}. One + * instance is registered per FHIR resource type; HAPI's annotation-driven dispatch routes + * {@code GET/POST/PUT/DELETE/SEARCH Resource[/id]} to the methods below, which delegate to the + * {@code IRepository} returned by {@link IRepositoryFactory#create(RequestDetails)}. + * + *

Until the canonical "gateway IRepository" lands (see ARCHITECTURE.md), this is the shim + * that lets a server backed solely by an {@code IRepository} accept full CRUD + search over REST. + * + *

Conditional CRUD: HAPI binds {@code If-None-Exist} (conditional create) and {@code If-Match} + * (conditional update/delete) to {@code @ConditionalUrlParam}; these flow into the request headers + * map passed to {@code IRepository}, where supporting backends can act on them. + */ +@SuppressWarnings("UnstableApiUsage") +public class RepositoryResourceProvider implements IResourceProvider { + + private final Class resourceType; + private final IRepositoryFactory repositoryFactory; + private final FhirContext fhirContext; + + public RepositoryResourceProvider( + Class resourceType, IRepositoryFactory repositoryFactory, FhirContext fhirContext) { + this.resourceType = resourceType; + this.repositoryFactory = repositoryFactory; + this.fhirContext = fhirContext; + } + + @Override + public Class getResourceType() { + return resourceType; + } + + @Read + public T read(@IdParam IIdType id, RequestDetails requestDetails) { + return repositoryFactory.create(requestDetails).read(resourceType, id, headersOf(requestDetails)); + } + + @Create + public MethodOutcome create( + @ResourceParam T resource, @ConditionalUrlParam String conditionalUrl, RequestDetails requestDetails) { + var headers = headersOf(requestDetails); + if (conditionalUrl != null && !conditionalUrl.isEmpty()) { + // Conditional-create per the FHIR spec is signalled via the If-None-Exist header, + // which HAPI also surfaces as a ConditionalUrlParam. Forward both forms. + headers.putIfAbsent(Constants.HEADER_IF_NONE_EXIST, conditionalUrl); + } + return repositoryFactory.create(requestDetails).create(resource, headers); + } + + @Update + public MethodOutcome update( + @ResourceParam T resource, + @IdParam IIdType id, + @ConditionalUrlParam String conditionalUrl, + RequestDetails requestDetails) { + if (conditionalUrl != null && !conditionalUrl.isEmpty()) { + // Conditional update by URL params (e.g. PUT /Patient?identifier=foo) is not yet + // routed to IRepository — fail loudly rather than silently doing the wrong thing. + throw new MethodNotAllowedException("Conditional update by URL is not supported by this server; " + + "use PUT /[type]/[id] with If-Match for optimistic concurrency."); + } + if (id != null && id.hasIdPart()) { + // Make the URL id authoritative; align body id so the repository sees one id. + resource.setId(id); + } + return repositoryFactory.create(requestDetails).update(resource, headersOf(requestDetails)); + } + + @Delete + public MethodOutcome delete( + @IdParam IIdType id, @ConditionalUrlParam String conditionalUrl, RequestDetails requestDetails) { + if (conditionalUrl != null && !conditionalUrl.isEmpty()) { + throw new MethodNotAllowedException( + "Conditional delete by URL is not supported by this server; " + "use DELETE /[type]/[id]."); + } + return repositoryFactory.create(requestDetails).delete(resourceType, id, headersOf(requestDetails)); + } + + /** + * Untyped search: convert raw URL params to typed {@code IQueryParameterType}s, hand to + * {@link ca.uhn.fhir.repository.IRepository#search}, return the bundle as a bundle provider so + * HAPI handles paging/serialization. + */ + @Search(allowUnknownParams = true) + public IBundleProvider search(RequestDetails requestDetails) { + var rawParams = requestDetails.getParameters(); + var typedParams = + SearchParameterTranslator.translate(fhirContext, fhirContext.getResourceType(resourceType), rawParams); + + @SuppressWarnings("unchecked") + Class bundleClass = + (Class) fhirContext.getResourceDefinition("Bundle").getImplementingClass(); + + IBaseBundle bundle = repositoryFactory + .create(requestDetails) + .search(bundleClass, resourceType, typedParams, headersOf(requestDetails)); + + List resources = BundleUtil.toListOfResources(fhirContext, bundle); + return new SimpleBundleProvider(resources); + } + + /** + * Snapshot the FHIR-relevant request headers HAPI tracks. {@code RequestDetails} doesn't + * expose an "all headers" view, so we forward only the headers {@code IRepository} + * implementations actually consult (concurrency control, conditional create, content + * negotiation hints). + */ + static Map headersOf(RequestDetails requestDetails) { + if (requestDetails == null) return Map.of(); + var headers = new java.util.HashMap(); + for (String name : FORWARDED_HEADERS) { + String value = requestDetails.getHeader(name); + if (value != null && !value.isEmpty()) { + headers.put(name, value); + } + } + return headers; + } + + private static final List FORWARDED_HEADERS = List.of( + Constants.HEADER_IF_MATCH, + Constants.HEADER_IF_NONE_MATCH, + Constants.HEADER_IF_NONE_EXIST, + Constants.HEADER_IF_MODIFIED_SINCE, + Constants.HEADER_PREFER); +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositoryRestProviderRegistrar.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositoryRestProviderRegistrar.java new file mode 100644 index 0000000000..eff51fce08 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositoryRestProviderRegistrar.java @@ -0,0 +1,74 @@ +package org.opencds.cqf.fhir.cr.dev.server; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.server.IRepositoryFactory; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.List; +import java.util.Set; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; + +/** + * On Spring context refresh, registers one {@link RepositoryResourceProvider} per FHIR resource + * type with the supplied {@link RestfulServer}, plus a single {@link RepositorySystemProvider} + * for system-level interactions ({@code POST /} bundle transactions). + * + *

If {@code resourceTypes} is null, every concrete resource type known to the + * {@link FhirContext} is registered. Pass an explicit list to scope the surface (e.g. expose + * only knowledge artifacts). + */ +@SuppressWarnings("UnstableApiUsage") +public class RepositoryRestProviderRegistrar { + + private static final Logger logger = LoggerFactory.getLogger(RepositoryRestProviderRegistrar.class); + + /** + * Abstract / infrastructure types that are not concrete resources but may appear in + * {@link FhirContext#getResourceTypes()} on some HAPI versions. + */ + private static final Set NON_REGISTERABLE = Set.of("Resource", "DomainResource"); + + private final RestfulServer restfulServer; + private final IRepositoryFactory repositoryFactory; + private final FhirContext fhirContext; + private final List resourceTypes; + + public RepositoryRestProviderRegistrar( + RestfulServer restfulServer, + IRepositoryFactory repositoryFactory, + FhirContext fhirContext, + List resourceTypes) { + this.restfulServer = restfulServer; + this.repositoryFactory = repositoryFactory; + this.fhirContext = fhirContext; + this.resourceTypes = resourceTypes; + } + + @EventListener(ContextRefreshedEvent.class) + public void registerProviders() { + var types = resourceTypes != null ? resourceTypes : allConcreteResourceTypes(); + for (String typeName : types) { + registerOne(typeName); + } + restfulServer.registerProvider(new RepositorySystemProvider(repositoryFactory)); + logger.info("Registered {} IRepository-backed CRUD providers + system provider", types.size()); + } + + private List allConcreteResourceTypes() { + return fhirContext.getResourceTypes().stream() + .filter(t -> !NON_REGISTERABLE.contains(t)) + .sorted() + .toList(); + } + + private void registerOne(String typeName) { + var def = fhirContext.getResourceDefinition(typeName); + @SuppressWarnings("unchecked") + Class implClass = (Class) def.getImplementingClass(); + var provider = new RepositoryResourceProvider<>(implClass, repositoryFactory, fhirContext); + restfulServer.registerProvider(provider); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositorySystemProvider.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositorySystemProvider.java new file mode 100644 index 0000000000..4be6249050 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/RepositorySystemProvider.java @@ -0,0 +1,28 @@ +package org.opencds.cqf.fhir.cr.dev.server; + +import ca.uhn.fhir.rest.annotation.Transaction; +import ca.uhn.fhir.rest.annotation.TransactionParam; +import ca.uhn.fhir.rest.api.server.IRepositoryFactory; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import org.hl7.fhir.instance.model.api.IBaseBundle; + +/** + * System-level provider (no resource type) that handles {@code POST /} bundle transactions and + * batches by delegating to {@link ca.uhn.fhir.repository.IRepository#transaction}. + */ +@SuppressWarnings("UnstableApiUsage") +public class RepositorySystemProvider { + + private final IRepositoryFactory repositoryFactory; + + public RepositorySystemProvider(IRepositoryFactory repositoryFactory) { + this.repositoryFactory = repositoryFactory; + } + + @Transaction + public IBaseBundle transaction(@TransactionParam IBaseBundle bundle, RequestDetails requestDetails) { + return repositoryFactory + .create(requestDetails) + .transaction(bundle, RepositoryResourceProvider.headersOf(requestDetails)); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ApplyOperationConfig.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ApplyOperationConfig.java new file mode 100644 index 0000000000..38bcaa6ddb --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ApplyOperationConfig.java @@ -0,0 +1,62 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.Arrays; +import java.util.Map; +import org.opencds.cqf.fhir.cr.hapi.common.IActivityDefinitionProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IGraphDefinitionApplyRequestBuilderFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IGraphDefinitionProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IPlanDefinitionProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.StringTimePeriodHandler; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.r4.activitydefinition.ActivityDefinitionApplyProvider; +import org.opencds.cqf.fhir.cr.hapi.r4.graphdefinition.GraphDefinitionApplyProvider; +import org.opencds.cqf.fhir.cr.hapi.r4.plandefinition.PlanDefinitionApplyProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ApplyOperationConfig { + @Bean + ActivityDefinitionApplyProvider r4ActivityDefinitionApplyProvider( + IActivityDefinitionProcessorFactory activityDefinitionProcessorFactory) { + return new ActivityDefinitionApplyProvider(activityDefinitionProcessorFactory); + } + + @Bean + PlanDefinitionApplyProvider r4PlanDefinitionApplyProvider( + IPlanDefinitionProcessorFactory planDefinitionProcessorFactory) { + return new PlanDefinitionApplyProvider(planDefinitionProcessorFactory); + } + + @Bean + GraphDefinitionApplyProvider r4GraphDefinitionApplyProvider( + IGraphDefinitionProcessorFactory graphDefinitionProcessorFactory, + IGraphDefinitionApplyRequestBuilderFactory graphDefinitionApplyRequestBuilderFactory, + FhirContext fhirContext, + StringTimePeriodHandler stringTimePeriodHandler) { + return new GraphDefinitionApplyProvider( + graphDefinitionProcessorFactory, + graphDefinitionApplyRequestBuilderFactory, + fhirContext.getVersion().getVersion(), + stringTimePeriodHandler); + } + + @Bean(name = "applyOperationLoader") + public ProviderLoader applyOperationLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + var selector = new ProviderSelector( + fhirContext, + Map.of( + fhirContext.getVersion().getVersion(), + Arrays.asList( + ActivityDefinitionApplyProvider.class, + PlanDefinitionApplyProvider.class, + GraphDefinitionApplyProvider.class))); + + return new ProviderLoader(restfulServer, applicationContext, selector); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/CqlOperationConfig.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/CqlOperationConfig.java new file mode 100644 index 0000000000..ad9f39b2e4 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/CqlOperationConfig.java @@ -0,0 +1,31 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.List; +import java.util.Map; +import org.opencds.cqf.fhir.cr.hapi.common.ICqlProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.r4.cql.CqlExecutionOperationProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CqlOperationConfig { + @Bean + CqlExecutionOperationProvider r4CqlExecutionOperationProvider(ICqlProcessorFactory cqlProcessorFactory) { + return new CqlExecutionOperationProvider(cqlProcessorFactory); + } + + @Bean(name = "cqlOperationLoader") + public ProviderLoader cqlOperationLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + var selector = new ProviderSelector( + fhirContext, Map.of(FhirVersionEnum.R4, List.of(CqlExecutionOperationProvider.class))); + + return new ProviderLoader(restfulServer, applicationContext, selector); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/CrProcessorConfig.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/CrProcessorConfig.java new file mode 100644 index 0000000000..222335b31d --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/CrProcessorConfig.java @@ -0,0 +1,98 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.rest.api.server.IRepositoryFactory; +import java.util.List; +import org.opencds.cqf.fhir.cql.EvaluationSettings; +import org.opencds.cqf.fhir.cr.CrSettings; +import org.opencds.cqf.fhir.cr.activitydefinition.ActivityDefinitionProcessor; +import org.opencds.cqf.fhir.cr.cql.CqlProcessor; +import org.opencds.cqf.fhir.cr.graphdefinition.GraphDefinitionProcessor; +import org.opencds.cqf.fhir.cr.graphdefinition.apply.ApplyRequestBuilder; +import org.opencds.cqf.fhir.cr.hapi.common.HapiArtifactDiffProcessor; +import org.opencds.cqf.fhir.cr.hapi.common.HapiCreateChangelogProcessor; +import org.opencds.cqf.fhir.cr.hapi.common.IActivityDefinitionProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.ICqlProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IGraphDefinitionApplyRequestBuilderFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IGraphDefinitionProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IImplementationGuideProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.ILibraryProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IPlanDefinitionProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IQuestionnaireProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IQuestionnaireResponseProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IValueSetProcessorFactory; +import org.opencds.cqf.fhir.cr.implementationguide.ImplementationGuideProcessor; +import org.opencds.cqf.fhir.cr.library.LibraryProcessor; +import org.opencds.cqf.fhir.cr.plandefinition.PlanDefinitionProcessor; +import org.opencds.cqf.fhir.cr.questionnaire.QuestionnaireProcessor; +import org.opencds.cqf.fhir.cr.questionnaireresponse.QuestionnaireResponseProcessor; +import org.opencds.cqf.fhir.cr.valueset.ValueSetProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@SuppressWarnings("UnstableApiUsage") +@Configuration +public class CrProcessorConfig { + @Bean + ICqlProcessorFactory cqlProcessorFactory(IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> new CqlProcessor(repositoryFactory.create(rd), crSettings); + } + + @Bean + IActivityDefinitionProcessorFactory activityDefinitionProcessorFactory( + IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> new ActivityDefinitionProcessor(repositoryFactory.create(rd), crSettings); + } + + @Bean + IImplementationGuideProcessorFactory implementationGuideProcessorFactory( + IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> new ImplementationGuideProcessor(repositoryFactory.create(rd), crSettings); + } + + @Bean + IPlanDefinitionProcessorFactory planDefinitionProcessorFactory( + IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> new PlanDefinitionProcessor(repositoryFactory.create(rd), crSettings); + } + + @Bean + IQuestionnaireProcessorFactory questionnaireProcessorFactory( + IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> new QuestionnaireProcessor(repositoryFactory.create(rd), crSettings); + } + + @Bean + IQuestionnaireResponseProcessorFactory questionnaireResponseProcessorFactory( + IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> new QuestionnaireResponseProcessor(repositoryFactory.create(rd), crSettings); + } + + @Bean + ILibraryProcessorFactory libraryProcessorFactory(IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> { + var repository = repositoryFactory.create(rd); + return new LibraryProcessor( + repository, + crSettings, + List.of(new HapiArtifactDiffProcessor(repository), new HapiCreateChangelogProcessor(repository))); + }; + } + + @Bean + IValueSetProcessorFactory valueSetProcessorFactory(IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> new ValueSetProcessor(repositoryFactory.create(rd), crSettings); + } + + @Bean + IGraphDefinitionProcessorFactory graphDefinitionProcessorFactory( + IRepositoryFactory repositoryFactory, CrSettings crSettings) { + return rd -> new GraphDefinitionProcessor(repositoryFactory.create(rd), crSettings); + } + + @Bean + IGraphDefinitionApplyRequestBuilderFactory graphDefinitionApplyRequestBuilderFactory( + IRepositoryFactory repositoryFactory, EvaluationSettings evaluationSettings) { + + return rd -> new ApplyRequestBuilder(repositoryFactory.create(rd), evaluationSettings); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/EvaluateOperationConfig.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/EvaluateOperationConfig.java new file mode 100644 index 0000000000..d59d1ddbf9 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/EvaluateOperationConfig.java @@ -0,0 +1,31 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.Arrays; +import java.util.Map; +import org.opencds.cqf.fhir.cr.hapi.common.ILibraryProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.r4.library.LibraryEvaluateProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class EvaluateOperationConfig { + @Bean + LibraryEvaluateProvider r4LibraryEvaluateProvider(ILibraryProcessorFactory libraryProcessorFactory) { + return new LibraryEvaluateProvider(libraryProcessorFactory); + } + + @Bean(name = "evaluateOperationLoader") + public ProviderLoader evaluateOperationLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + var selector = new ProviderSelector( + fhirContext, Map.of(FhirVersionEnum.R4, Arrays.asList(LibraryEvaluateProvider.class))); + + return new ProviderLoader(restfulServer, applicationContext, selector); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ExtractOperationConfig.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ExtractOperationConfig.java new file mode 100644 index 0000000000..8ec733c919 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ExtractOperationConfig.java @@ -0,0 +1,32 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.Arrays; +import java.util.Map; +import org.opencds.cqf.fhir.cr.hapi.common.IQuestionnaireResponseProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.r4.questionnaireresponse.QuestionnaireResponseExtractProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ExtractOperationConfig { + @Bean + QuestionnaireResponseExtractProvider r4QuestionnaireResponseExtractProvider( + IQuestionnaireResponseProcessorFactory questionnaireResponseProcessorFactory) { + return new QuestionnaireResponseExtractProvider(questionnaireResponseProcessorFactory); + } + + @Bean(name = "extractOperationLoader") + public ProviderLoader extractOperationLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + var selector = new ProviderSelector( + fhirContext, Map.of(FhirVersionEnum.R4, Arrays.asList(QuestionnaireResponseExtractProvider.class))); + + return new ProviderLoader(restfulServer, applicationContext, selector); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/PackageOperationConfig.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/PackageOperationConfig.java new file mode 100644 index 0000000000..9e456657ea --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/PackageOperationConfig.java @@ -0,0 +1,70 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.Arrays; +import java.util.Map; +import org.opencds.cqf.fhir.cr.hapi.common.IGraphDefinitionProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.ILibraryProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IPlanDefinitionProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IQuestionnaireProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.common.IValueSetProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.r4.graphdefinition.GraphDefinitionPackageProvider; +import org.opencds.cqf.fhir.cr.hapi.r4.library.LibraryPackageProvider; +import org.opencds.cqf.fhir.cr.hapi.r4.plandefinition.PlanDefinitionPackageProvider; +import org.opencds.cqf.fhir.cr.hapi.r4.questionnaire.QuestionnairePackageProvider; +import org.opencds.cqf.fhir.cr.hapi.r4.valueset.ValueSetPackageProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PackageOperationConfig { + @Bean + PlanDefinitionPackageProvider r4PlanDefinitionPackageProvider( + IPlanDefinitionProcessorFactory planDefinitionProcessorFactory) { + return new PlanDefinitionPackageProvider(planDefinitionProcessorFactory); + } + + @Bean + QuestionnairePackageProvider r4QuestionnairePackageProvider( + IQuestionnaireProcessorFactory questionnaireProcessorFactory) { + return new QuestionnairePackageProvider(questionnaireProcessorFactory); + } + + @Bean + LibraryPackageProvider r4LibraryPackageProvider(ILibraryProcessorFactory libraryProcessorFactory) { + return new LibraryPackageProvider(libraryProcessorFactory); + } + + @Bean + ValueSetPackageProvider r4ValueSetPackageProvider(IValueSetProcessorFactory valueSetProcessorFactory) { + return new ValueSetPackageProvider(valueSetProcessorFactory); + } + + @Bean + GraphDefinitionPackageProvider r4GraphDefinitionPackageProvider( + IGraphDefinitionProcessorFactory graphDefinitionProcessorFactory) { + return new GraphDefinitionPackageProvider(graphDefinitionProcessorFactory); + } + + @Bean(name = "packageOperationLoader") + public ProviderLoader packageOperationLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + var selector = new ProviderSelector( + fhirContext, + Map.of( + FhirVersionEnum.R4, + Arrays.asList( + LibraryPackageProvider.class, + QuestionnairePackageProvider.class, + PlanDefinitionPackageProvider.class, + ValueSetPackageProvider.class, + GraphDefinitionPackageProvider.class))); + + return new ProviderLoader(restfulServer, applicationContext, selector); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/PopulateOperationConfig.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/PopulateOperationConfig.java new file mode 100644 index 0000000000..baeb6f73e5 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/PopulateOperationConfig.java @@ -0,0 +1,32 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.Arrays; +import java.util.Map; +import org.opencds.cqf.fhir.cr.hapi.common.IQuestionnaireProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.r4.questionnaire.QuestionnairePopulateProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PopulateOperationConfig { + @Bean + QuestionnairePopulateProvider r4QuestionnairePopulateProvider( + IQuestionnaireProcessorFactory questionnaireProcessorFactory) { + return new QuestionnairePopulateProvider(questionnaireProcessorFactory); + } + + @Bean(name = "populateOperationLoader") + public ProviderLoader populateOperationLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + var selector = new ProviderSelector( + fhirContext, Map.of(FhirVersionEnum.R4, Arrays.asList(QuestionnairePopulateProvider.class))); + + return new ProviderLoader(restfulServer, applicationContext, selector); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/QuestionnaireOperationConfig.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/QuestionnaireOperationConfig.java new file mode 100644 index 0000000000..8cceff17de --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/QuestionnaireOperationConfig.java @@ -0,0 +1,32 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.Arrays; +import java.util.Map; +import org.opencds.cqf.fhir.cr.hapi.common.IQuestionnaireProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.r4.structuredefinition.StructureDefinitionQuestionnaireProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuestionnaireOperationConfig { + @Bean + StructureDefinitionQuestionnaireProvider r4StructureDefinitionQuestionnaireProvider( + IQuestionnaireProcessorFactory questionnaireProcessorFactory) { + return new StructureDefinitionQuestionnaireProvider(questionnaireProcessorFactory); + } + + @Bean(name = "questionnaireOperationLoader") + public ProviderLoader questionnaireOperationLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + var selector = new ProviderSelector( + fhirContext, Map.of(FhirVersionEnum.R4, Arrays.asList(StructureDefinitionQuestionnaireProvider.class))); + + return new ProviderLoader(restfulServer, applicationContext, selector); + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ServerProperties.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ServerProperties.java new file mode 100644 index 0000000000..6f01a92cde --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ServerProperties.java @@ -0,0 +1,41 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Server-level configuration. Exposed under the {@code cqf.server.*} prefix in + * {@code application.yml}. + * + *

+ * cqf:
+ *   server:
+ *     base-path: /fhir          # servlet mount path
+ *     fhir-version: R4          # R4 or DSTU3
+ * 
+ */ +@ConfigurationProperties(prefix = "cqf.server") +public class ServerProperties { + + // S1075 ("hardcoded URI") doesn't apply: this *is* the customizable parameter — the default + // for a @ConfigurationProperties field overridden via cqf.server.base-path in application.yml. + @SuppressWarnings("java:S1075") + private String basePath = "/fhir"; + + private String fhirVersion = "R4"; + + public String getBasePath() { + return basePath; + } + + public void setBasePath(String basePath) { + this.basePath = basePath; + } + + public String getFhirVersion() { + return fhirVersion; + } + + public void setFhirVersion(String fhirVersion) { + this.fhirVersion = fhirVersion; + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ServerR4Config.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ServerR4Config.java new file mode 100644 index 0000000000..1db312cdf5 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/config/ServerR4Config.java @@ -0,0 +1,170 @@ +package org.opencds.cqf.fhir.cr.dev.server.config; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.repository.IRepository; +import ca.uhn.fhir.rest.api.server.IRepositoryFactory; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import org.opencds.cqf.fhir.cql.EvaluationSettings; +import org.opencds.cqf.fhir.cr.CrSettings; +import org.opencds.cqf.fhir.cr.dev.server.RepositoryRestProviderRegistrar; +import org.opencds.cqf.fhir.cr.hapi.common.StringTimePeriodHandler; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.r4.R4MeasureEvaluatorMultipleFactory; +import org.opencds.cqf.fhir.cr.hapi.r4.R4MeasureEvaluatorSingleFactory; +import org.opencds.cqf.fhir.cr.hapi.r4.measure.MeasureOperationsProvider; +import org.opencds.cqf.fhir.cr.measure.MeasureEvaluationOptions; +import org.opencds.cqf.fhir.cr.measure.common.MeasurePeriodValidator; +import org.opencds.cqf.fhir.cr.measure.r4.R4MultiMeasureService; +import org.opencds.cqf.fhir.utility.client.TerminologyServerClientSettings; +import org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * R4 server wiring. Provides every bean needed to stand up a clinical reasoning server backed by + * an in-memory {@link IRepository}: FHIR context, RestfulServer, the operation provider chain, + * the CRUD shim, and the servlet mount. + * + *

This config does not {@code @Import} {@code CrR4Config} because that drags in the JPA + * {@code RepositoryConfig} (requires {@code DaoRegistry}). The relevant beans are recreated here + * with {@link IRepositoryFactory} returning the in-memory repository instead. + */ +@SuppressWarnings("UnstableApiUsage") +@Configuration +@EnableConfigurationProperties(ServerProperties.class) +public class ServerR4Config { + + @Bean + public FhirContext fhirContext(ServerProperties properties) { + return FhirContext.forCached(FhirVersionEnum.valueOf(properties.getFhirVersion())); + } + + @Bean + public RestfulServer restfulServer(FhirContext fhirContext) { + return new RestfulServer(fhirContext); + } + + @Bean + public ServletRegistrationBean restfulServerRegistration( + RestfulServer restfulServer, ServerProperties properties) { + var basePath = properties.getBasePath(); + var pattern = basePath.endsWith("/") ? basePath + "*" : basePath + "/*"; + var registration = new ServletRegistrationBean<>(restfulServer, pattern); + registration.setName("fhirServlet"); + return registration; + } + + @Bean + public IRepository inMemoryRepository(FhirContext fhirContext) { + return new InMemoryFhirRepository(fhirContext); + } + + @Bean + public IRepositoryFactory repositoryFactory(IRepository inMemoryRepository) { + // Single shared in-memory store; ignore RequestDetails (no per-tenant scoping yet). + return rd -> inMemoryRepository; + } + + @Bean + public RepositoryRestProviderRegistrar repositoryProviderRegistrar( + RestfulServer restfulServer, IRepositoryFactory repositoryFactory, FhirContext fhirContext) { + // null = register every concrete resource type known to the FhirContext. + return new RepositoryRestProviderRegistrar(restfulServer, repositoryFactory, fhirContext, null); + } + + // -------------------- CR operation chain -------------------- + // Mirrors the bean shape of CrR4Config but without that class's @Import on the JPA + // RepositoryConfig. When CrR4Config is split upstream, replace this section with an @Import + // of the operation-only config. + + @Bean + public EvaluationSettings evaluationSettings() { + return EvaluationSettings.getDefault() + .withRegisteredNamespaces(Map.of( + "hl7.fhir.uv.cql", + "http://hl7.org/fhir/uv/cql", + "nhl7.fhir.us.cql", + "http://hl7.org/fhir/us/cql")); + } + + @Bean + public TerminologyServerClientSettings terminologyServerClientSettings() { + return TerminologyServerClientSettings.getDefault(); + } + + @Bean + public CrSettings crSettings( + EvaluationSettings evaluationSettings, TerminologyServerClientSettings terminologySettings) { + return new CrSettings() + .withEvaluationSettings(evaluationSettings) + .withTerminologyServerClientSettings(terminologySettings); + } + + @Bean + public MeasureEvaluationOptions measureEvaluationOptions() { + return MeasureEvaluationOptions.defaultOptions(); + } + + @Bean + public MeasurePeriodValidator measurePeriodValidator() { + return new MeasurePeriodValidator(); + } + + @Bean + public StringTimePeriodHandler stringTimePeriodHandler() { + return new StringTimePeriodHandler(ZoneOffset.UTC); + } + + @Bean + public R4MeasureEvaluatorSingleFactory r4MeasureEvaluatorSingleFactory( + IRepositoryFactory repositoryFactory, + MeasureEvaluationOptions evaluationOptions, + MeasurePeriodValidator measurePeriodValidator) { + return (requestDetails, environment) -> new R4MultiMeasureService( + environment.resolve(repositoryFactory.create(requestDetails)), + evaluationOptions, + requestDetails.getFhirServerBase(), + measurePeriodValidator); + } + + @Bean + public R4MeasureEvaluatorMultipleFactory r4MeasureEvaluatorMultipleFactory( + IRepositoryFactory repositoryFactory, + MeasureEvaluationOptions evaluationOptions, + MeasurePeriodValidator measurePeriodValidator) { + return (requestDetails, environment) -> new R4MultiMeasureService( + environment.resolve(repositoryFactory.create(requestDetails)), + evaluationOptions, + requestDetails.getFhirServerBase(), + measurePeriodValidator); + } + + @Bean + public MeasureOperationsProvider r4MeasureOperationsProvider( + R4MeasureEvaluatorSingleFactory single, + R4MeasureEvaluatorMultipleFactory multi, + StringTimePeriodHandler timeHandler) { + return new MeasureOperationsProvider(single, multi, timeHandler); + } + + @Bean + public ProviderLoader providerLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + var selector = + new ProviderSelector(fhirContext, Map.of(FhirVersionEnum.R4, List.of(MeasureOperationsProvider.class))); + return new ProviderLoader(restfulServer, applicationContext, selector); + } + + // RestfulServer.init() runs naturally when Tomcat initializes the servlet — at that point + // ProviderLoader has already registered all providers (it fires on ContextRefreshedEvent, + // which precedes servlet container startup). Manually pre-warming via @PostConstruct caused + // double-registration warnings because @PostConstruct runs before ContextRefreshedEvent. +} diff --git a/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/search/SearchParameterTranslator.java b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/search/SearchParameterTranslator.java new file mode 100644 index 0000000000..a5f38887c3 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/java/org/opencds/cqf/fhir/cr/dev/server/search/SearchParameterTranslator.java @@ -0,0 +1,116 @@ +package org.opencds.cqf.fhir.cr.dev.server.search; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeResourceDefinition; +import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.DateParam; +import ca.uhn.fhir.rest.param.NumberParam; +import ca.uhn.fhir.rest.param.QuantityParam; +import ca.uhn.fhir.rest.param.ReferenceParam; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.rest.param.TokenParam; +import ca.uhn.fhir.rest.param.UriParam; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Converts raw URL query parameters (as HAPI exposes them via + * {@code RequestDetails.getParameters()}) into the typed {@code IQueryParameterType} form that + * {@link ca.uhn.fhir.repository.IRepository#search} expects. + * + *

Skips response-shaping params ({@code _format}, {@code _count}, etc.) since those are HAPI's + * concern, not the repository's. Resolves each remaining param's type via the + * {@link FhirContext}'s search-parameter registry; unknown names default to {@link StringParam}. + */ +public final class SearchParameterTranslator { + + /** Response-shaping params handled by HAPI; the repository doesn't see them. */ + private static final Set RESPONSE_SHAPING = Set.of( + "_format", + "_pretty", + "_summary", + "_elements", + "_count", + "_offset", + "_total", + "_sort", + "_include", + "_revinclude"); + + private SearchParameterTranslator() {} + + public static Multimap> translate( + FhirContext fhirContext, String resourceType, Map rawParams) { + var def = fhirContext.getResourceDefinition(resourceType); + var out = ArrayListMultimap.>create(); + for (var entry : rawParams.entrySet()) { + translateEntry(fhirContext, def, entry.getKey(), entry.getValue(), out); + } + return out; + } + + private static void translateEntry( + FhirContext fhirContext, + RuntimeResourceDefinition def, + String fullName, + String[] values, + Multimap> out) { + if (values == null || values.length == 0) { + return; + } + int colonIdx = fullName.indexOf(':'); + String name = colonIdx < 0 ? fullName : fullName.substring(0, colonIdx); + String qualifier = colonIdx < 0 ? null : fullName.substring(colonIdx); + if (RESPONSE_SHAPING.contains(name)) { + return; + } + var type = resolveType(name, def); + for (String raw : values) { + IQueryParameterType param = create(type, fhirContext, name, qualifier, raw); + out.put(name, List.of(param)); + } + } + + private static RestSearchParameterTypeEnum resolveType(String name, RuntimeResourceDefinition def) { + // _id is universally a token (FHIR R4 spec). + if ("_id".equals(name)) return RestSearchParameterTypeEnum.TOKEN; + if ("_profile".equals(name)) return RestSearchParameterTypeEnum.URI; + if ("_tag".equals(name) || "_security".equals(name) || "_source".equals(name)) + return RestSearchParameterTypeEnum.TOKEN; + if ("_lastUpdated".equals(name)) return RestSearchParameterTypeEnum.DATE; + + RuntimeSearchParam runtime = def.getSearchParam(name); + return runtime != null ? runtime.getParamType() : RestSearchParameterTypeEnum.STRING; + } + + private static IQueryParameterType create( + RestSearchParameterTypeEnum type, FhirContext fhirContext, String name, String qualifier, String rawValue) { + IQueryParameterType param = + switch (type) { + case TOKEN -> new TokenParam(); + case STRING -> new StringParam(); + case REFERENCE -> new ReferenceParam(); + case URI -> new UriParam(); + case DATE -> new DateParam(); + case NUMBER -> new NumberParam(); + case QUANTITY -> new QuantityParam(); + // COMPOSITE / HAS / SPECIAL — fall back to a string match; refine when needed. + default -> new StringParam(); + }; + try { + param.setValueAsQueryToken(fhirContext, name, qualifier, rawValue); + return param; + } catch (RuntimeException ex) { + // Unparseable value for the chosen type — fall back to a permissive string match + // so the request fails with empty results rather than a 500. + var fallback = new StringParam(); + fallback.setValueAsQueryToken(fhirContext, name, qualifier, rawValue); + return fallback; + } + } +} diff --git a/cqf-fhir-cr-dev-server/src/main/resources/application.yml b/cqf-fhir-cr-dev-server/src/main/resources/application.yml new file mode 100644 index 0000000000..9f5ba58496 --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/main/resources/application.yml @@ -0,0 +1,28 @@ +spring: + main: + banner-mode: "off" + # JPA/JDBC/Quartz are excluded in @SpringBootApplication. Other surprise auto-configurations + # triggered by HAPI's transitive deps (Hibernate Search → Elasticsearch, Thymeleaf, etc.) are + # excluded here so the list can grow without recompiling. + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchClientAutoConfiguration + - org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration + - org.springframework.boot.autoconfigure.elasticsearch.ReactiveElasticsearchClientAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ReactiveElasticsearchRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration + +server: + port: 8080 + +cqf: + server: + base-path: /fhir + fhir-version: R4 + +logging: + level: + ca.uhn.fhir: INFO + org.opencds.cqf: INFO diff --git a/cqf-fhir-cr-dev-server/src/test/java/org/opencds/cqf/fhir/cr/dev/server/ServerIntegrationTest.java b/cqf-fhir-cr-dev-server/src/test/java/org/opencds/cqf/fhir/cr/dev/server/ServerIntegrationTest.java new file mode 100644 index 0000000000..5d3f1cfc4c --- /dev/null +++ b/cqf-fhir-cr-dev-server/src/test/java/org/opencds/cqf/fhir/cr/dev/server/ServerIntegrationTest.java @@ -0,0 +1,310 @@ +package org.opencds.cqf.fhir.cr.dev.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.IRepository; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Measure; +import org.hl7.fhir.r4.model.Observation; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Integration test against the real {@link Application} bootstrapping path: Spring Boot starts + * embedded Tomcat, mounts {@code RestfulServer} as a servlet, registers the operation provider + * (Measure $evaluate-measure) and a {@link RepositoryResourceProvider} per FHIR resource type, + * plus the {@link RepositorySystemProvider} for transactions. + * + *

Verifies the full path: HTTP → HAPI dispatch → CRUD shim → {@code IRepository}. + */ +@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ServerIntegrationTest { + + @Value("${local.server.port}") + int port; + + @Autowired + IRepository repo; + + @Autowired + FhirContext fhirContext; + + @Autowired + RestfulServer restfulServer; + + private HttpClient http; + private String baseUrl; + + @BeforeAll + void setupClient() { + http = HttpClient.newHttpClient(); + baseUrl = "http://localhost:" + port + "/fhir"; + System.out.println("Server URL: " + baseUrl); + } + + @Test + @Order(1) + void metadata_advertises_evaluate_measure() throws Exception { + var resp = get("/metadata"); + assertEquals(200, resp.statusCode(), () -> "body:\n" + resp.body()); + assertTrue(resp.body().contains("CapabilityStatement")); + assertTrue(resp.body().contains("evaluate-measure"), "$evaluate-measure should be advertised"); + // Sanity: every concrete R4 resource type should appear in the rest.resource list. + // This will catch a mass registration regression early. + assertTrue(resp.body().contains("\"type\":\"Patient\""), "Patient resource should be advertised"); + assertTrue(resp.body().contains("\"type\":\"Observation\""), "Observation resource should be advertised"); + } + + @Test + @Order(2) + void post_patient_persists_via_shim() throws Exception { + var patient = new Patient().setActive(true); + patient.addName().setFamily("Shimwell").addGiven("Alice"); + + var resp = post("/Patient", encode(patient)); + assertEquals(201, resp.statusCode(), () -> "body:\n" + resp.body()); + + var location = resp.headers().firstValue("Location").orElse(""); + assertTrue(location.contains("/Patient/"), "Location should reference new Patient: " + location); + + var newId = idFromLocation(location, "Patient"); + var stored = repo.read(Patient.class, new IdType("Patient", newId)); + assertNotNull(stored); + assertEquals("Shimwell", stored.getNameFirstRep().getFamily()); + } + + @Test + @Order(3) + void get_patient_reads_from_repository() throws Exception { + var seeded = new Patient().setActive(true); + seeded.setId(new IdType("Patient", "alice")); + seeded.addName().setFamily("Read").addGiven("Alice"); + repo.update(seeded); + + var resp = get("/Patient/alice"); + assertEquals(200, resp.statusCode(), () -> "body:\n" + resp.body()); + assertTrue(resp.body().contains("\"family\":\"Read\"")); + } + + @Test + @Order(4) + void put_patient_updates_repository() throws Exception { + var seeded = new Patient().setActive(true); + seeded.setId(new IdType("Patient", "to-update")); + seeded.addName().setFamily("Original"); + repo.update(seeded); + + var modified = new Patient().setActive(false); + modified.setId(new IdType("Patient", "to-update")); + modified.addName().setFamily("Modified"); + + var resp = put("/Patient/to-update", encode(modified)); + assertTrue( + resp.statusCode() == 200 || resp.statusCode() == 201, + () -> "expected 200 or 201, got " + resp.statusCode() + ": " + resp.body()); + + var stored = repo.read(Patient.class, new IdType("Patient", "to-update")); + assertEquals("Modified", stored.getNameFirstRep().getFamily()); + assertEquals(false, stored.getActive()); + } + + @Test + @Order(5) + void delete_patient_removes_from_repository() throws Exception { + var seeded = new Patient(); + seeded.setId(new IdType("Patient", "to-delete")); + seeded.addName().setFamily("Doomed"); + repo.update(seeded); + + var resp = delete("/Patient/to-delete"); + assertTrue( + resp.statusCode() >= 200 && resp.statusCode() < 300, + () -> "expected 2xx; got " + resp.statusCode() + ": " + resp.body()); + + assertThrows( + ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException.class, + () -> repo.read(Patient.class, new IdType("Patient", "to-delete")), + "Patient should be gone from in-memory repo"); + } + + /** + * Search by id — exercises the SearchParameterTranslator (raw URL → typed params), + * IRepository.search(), and HAPI's bundle serialization on the way back out. + */ + @Test + @Order(6) + void search_patient_by_id_via_shim() throws Exception { + var seeded = new Patient(); + seeded.setId(new IdType("Patient", "search-target")); + seeded.addName().setFamily("SearchHit"); + repo.update(seeded); + + var resp = get("/Patient?_id=search-target"); + assertEquals(200, resp.statusCode(), () -> "body:\n" + resp.body()); + assertTrue(resp.body().contains("\"resourceType\":\"Bundle\"")); + assertTrue( + resp.body().contains("\"family\":\"SearchHit\""), + () -> "expected matched Patient in bundle, got: " + + resp.body().substring(0, Math.min(500, resp.body().length()))); + } + + /** + * Transaction bundle: POST a Bundle of type=transaction with multiple entries to base URL. + * Verifies the {@link RepositorySystemProvider} routes through to {@code IRepository.transaction}. + */ + @Test + @Order(7) + void post_transaction_bundle_processes_all_entries() throws Exception { + var bundle = new Bundle().setType(Bundle.BundleType.TRANSACTION); + + var p = new Patient(); + p.setId(new IdType("Patient", "txn-patient")); + p.addName().setFamily("Transactional"); + bundle.addEntry() + .setFullUrl("urn:uuid:1") + .setResource(p) + .getRequest() + .setMethod(Bundle.HTTPVerb.PUT) + .setUrl("Patient/txn-patient"); + + var o = new Observation(); + o.setId(new IdType("Observation", "txn-obs")); + o.setStatus(Observation.ObservationStatus.FINAL); + bundle.addEntry() + .setFullUrl("urn:uuid:2") + .setResource(o) + .getRequest() + .setMethod(Bundle.HTTPVerb.PUT) + .setUrl("Observation/txn-obs"); + + var resp = post("/", encode(bundle)); + assertTrue( + resp.statusCode() == 200 || resp.statusCode() == 201, + () -> "expected 200/201; got " + resp.statusCode() + ": " + resp.body()); + + // Verify both entries actually persisted via direct IRepository read. + var patient = repo.read(Patient.class, new IdType("Patient", "txn-patient")); + assertEquals("Transactional", patient.getNameFirstRep().getFamily()); + var obs = repo.read(Observation.class, new IdType("Observation", "txn-obs")); + assertEquals(Observation.ObservationStatus.FINAL, obs.getStatus()); + } + + /** + * Conditional create via {@code If-None-Exist} header. The header is forwarded to + * {@code IRepository.create}; the in-memory repo doesn't act on it (always creates), but + * this test pins down the pass-through so a richer repo (RestRepository, IgRepository) can + * honor it. + */ + @Test + @Order(8) + void conditional_create_forwards_if_none_exist_header() throws Exception { + var p = new Patient(); + p.addName().setFamily("Conditional"); + + var resp = http.send( + HttpRequest.newBuilder(URI.create(baseUrl + "/Patient")) + .header("Content-Type", "application/fhir+json") + .header("If-None-Exist", "identifier=foo|bar") + .POST(HttpRequest.BodyPublishers.ofString(encode(p))) + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(201, resp.statusCode(), () -> "body:\n" + resp.body()); + // No assertion on header arrival here: InMemoryFhirRepository ignores the header. The + // pass-through is exercised; a proper assertion lives in a unit test against the shim. + } + + /** + * $evaluate-measure still routes through. Uses the same dispatcher; its routing is the + * "operation" half of the Phase-1 trace. + */ + @Test + @Order(9) + void evaluate_measure_routes_through_repository() throws Exception { + var measure = new Measure(); + measure.setId(new IdType("Measure", "spike-measure")); + measure.setUrl("http://example.org/Measure/spike-measure"); + measure.setStatus(org.hl7.fhir.r4.model.Enumerations.PublicationStatus.ACTIVE); + repo.update(measure); + + var url = "/Measure/spike-measure/$evaluate-measure" + + "?periodStart=2024-01-01&periodEnd=2024-12-31&reportType=population"; + var resp = get(url); + + // Either a successful evaluation or an OperationOutcome from the processor — both prove + // the dispatcher reached the operation, which reached IRepository to read the Measure. + assertNotEquals(404, resp.statusCode(), "operation route not found"); + assertTrue( + resp.body().contains("MeasureReport") || resp.body().contains("OperationOutcome"), + () -> "expected MeasureReport/OperationOutcome; got: " + resp.body()); + } + + // ---------------- helpers ---------------- + + private HttpResponse get(String path) throws Exception { + return http.send( + HttpRequest.newBuilder(URI.create(baseUrl + path)) + .header("Accept", "application/fhir+json") + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse post(String path, String body) throws Exception { + return http.send( + HttpRequest.newBuilder(URI.create(baseUrl + path)) + .header("Content-Type", "application/fhir+json") + .header("Accept", "application/fhir+json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse put(String path, String body) throws Exception { + return http.send( + HttpRequest.newBuilder(URI.create(baseUrl + path)) + .header("Content-Type", "application/fhir+json") + .header("Accept", "application/fhir+json") + .PUT(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse delete(String path) throws Exception { + return http.send( + HttpRequest.newBuilder(URI.create(baseUrl + path)) + .header("Accept", "application/fhir+json") + .DELETE() + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private String encode(org.hl7.fhir.instance.model.api.IBaseResource r) { + return fhirContext.newJsonParser().encodeResourceToString(r); + } + + private static String idFromLocation(String location, String resourceType) { + var marker = "/" + resourceType + "/"; + return location.substring(location.indexOf(marker) + marker.length()).replaceAll("/_history.*", ""); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9fc3d9577..23d4fabf92 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,10 @@ jakarta-servlet-api = { module = "jakarta.servlet:jakarta.servlet-api" } # Spring spring-context = { module = "org.springframework:spring-context", version.ref = "spring" } spring-test = { module = "org.springframework:spring-test", version.ref = "spring" } +spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" } +spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } +spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } +spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot" } # Logging slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a768ca97eb..3a665c639b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,6 +29,8 @@ include("cqf-fhir-cr-spring") include("cqf-fhir-cr-cli") +include("cqf-fhir-cr-dev-server") + include("cqf-fhir-benchmark") include("docs")