Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions cqf-fhir-cr-dev-server/README.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 78 additions & 0 deletions cqf-fhir-cr-dev-server/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<org.springframework.boot.gradle.tasks.bundling.BootJar>("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>("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>("bootBuildImage") {
imageName.set("cqf-fhir-cr-dev-server:${project.version}")
environment.put("BP_JVM_VERSION", "17")
}
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -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)}.
*
* <p>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.
*
* <p>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<T extends IBaseResource> implements IResourceProvider {

private final Class<T> resourceType;
private final IRepositoryFactory repositoryFactory;
private final FhirContext fhirContext;

public RepositoryResourceProvider(
Class<T> resourceType, IRepositoryFactory repositoryFactory, FhirContext fhirContext) {
this.resourceType = resourceType;
this.repositoryFactory = repositoryFactory;
this.fhirContext = fhirContext;
}

@Override
public Class<T> 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<IBaseBundle> bundleClass =
(Class<IBaseBundle>) fhirContext.getResourceDefinition("Bundle").getImplementingClass();

IBaseBundle bundle = repositoryFactory
.create(requestDetails)
.search(bundleClass, resourceType, typedParams, headersOf(requestDetails));

List<IBaseResource> 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<String, String> headersOf(RequestDetails requestDetails) {
if (requestDetails == null) return Map.of();
var headers = new java.util.HashMap<String, String>();
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<String> 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);
}
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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<String> NON_REGISTERABLE = Set.of("Resource", "DomainResource");

private final RestfulServer restfulServer;
private final IRepositoryFactory repositoryFactory;
private final FhirContext fhirContext;
private final List<String> resourceTypes;

public RepositoryRestProviderRegistrar(
RestfulServer restfulServer,
IRepositoryFactory repositoryFactory,
FhirContext fhirContext,
List<String> 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<String> allConcreteResourceTypes() {
return fhirContext.getResourceTypes().stream()
.filter(t -> !NON_REGISTERABLE.contains(t))
.sorted()
.toList();
}

private <T extends IBaseResource> void registerOne(String typeName) {
var def = fhirContext.getResourceDefinition(typeName);
@SuppressWarnings("unchecked")
Class<T> implClass = (Class<T>) def.getImplementingClass();
var provider = new RepositoryResourceProvider<>(implClass, repositoryFactory, fhirContext);
restfulServer.registerProvider(provider);
}
}
Loading
Loading