Compare commits

..

15 Commits

Author SHA1 Message Date
Patrick Werner
c8d4ced193 Merge pull request #926 from hapifhir/cors-expose-header-config-optiones
Make CORS headers configurable and include ETag/If-Match defaults for browser FHIR clients
2026-03-17 09:35:05 +01:00
Patrick Werner
2ce85f064f feat: update CORS configuration to set allow_Credentials default to false 2026-03-12 20:03:50 +01:00
Patrick Werner
8069b7019a feat: enhance CORS configuration with customizable headers and methods 2026-03-12 19:44:31 +01:00
c-schuler
01f4bc2ce9 Merge pull request #921 from hapifhir/health-check
Standalone Java Health Check & Hibernate Dialect Override Fix
2026-03-10 09:18:34 -06:00
dotasek
3f0170b55d Merge pull request #895 from hapifhir/rel_8_7-tracking
HAPI 8.8.0 Release Tracking Branch
2026-02-23 09:36:03 -05:00
Brenin Rhodes
f267209b0e Update CR to 4.4.0 2026-02-20 16:56:18 -07:00
c-schuler
3edd75bf72 Spotless 2026-02-19 12:32:59 -07:00
c-schuler
8f3335e8bb Standalone Java health check
- Updated Dockerfile to compile HealthCheck.java into the distroless image at /app
- Updated README with a "Docker Health Check" section documenting how to run the health check on-demand and how to enable periodic checks
- Also fixed Hibernate dialect override (SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT → HIBERNATE_DIALECT) to resolve DDL failures when starting with a fresh PostgreSQL database.
2026-02-19 12:23:18 -07:00
dotasek
c0a22e1e47 Merge remote-tracking branch 'origin/master' into rel_8_7-tracking 2026-02-19 13:33:23 -05:00
dotasek
85266820db Use HAPI version 8.8.0 2026-02-19 13:30:45 -05:00
dotasek
53837d6c94 Merge pull request #920 from hapifhir/do-20250217-hapi-8-6-5
HAPI 8.6.5
2026-02-17 14:32:35 -05:00
Patrick Werner
5778f431bf chore: set Maven memory options in smoke-tests.yml 2026-02-17 19:29:30 +01:00
Patrick Werner
68fc2a4c04 fix formatting 2026-02-17 19:21:18 +01:00
dotasek
b80687959f Adjust for moved class 2026-02-17 13:17:15 -05:00
dotasek
ba077ab8fd HAPI 8.6.5 2026-02-17 12:58:02 -05:00
12 changed files with 255 additions and 48 deletions

View File

@@ -30,6 +30,8 @@ jobs:
distribution: zulu
- name: Build with Maven
env:
MAVEN_OPTS: -Xms256m -Xmx3g -XX:+UseG1GC
run: mvn -B package --file pom.xml -Dmaven.test.skip=true
- name: Docker Pull HTTP client

View File

@@ -15,6 +15,9 @@ FROM build-hapi AS build-distroless
RUN mvn package -DskipTests spring-boot:repackage -Pboot
RUN mkdir /app && cp /tmp/hapi-fhir-jpaserver-starter/target/ROOT.war /app/main.war
COPY src/main/java/HealthCheck.java /app/HealthCheck.java
RUN javac /app/HealthCheck.java
########### Use the official Tomcat image as base image for the Tomcat variant
########### it can be built using eg. `docker build --target tomcat .`

View File

@@ -49,6 +49,42 @@ 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.
### CORS configuration (including ETag/If-Match)
The starter CORS configuration now supports the following configurable keys:
- `hapi.fhir.cors.allowed_origin`
- `hapi.fhir.cors.allow_Credentials`
- `hapi.fhir.cors.allowed_headers`
- `hapi.fhir.cors.exposed_headers`
- `hapi.fhir.cors.allowed_methods`
Defaults include `If-Match` in allowed headers and `ETag` in exposed headers to support browser-based optimistic locking workflows.
The `allowed_headers`, `exposed_headers`, and `allowed_methods` keys are optional; if omitted, built-in defaults are applied.
The default for `allow_Credentials` is `false`. If you set `allow_Credentials=true`, do not use `"*"` for `allowed_origin`; configure explicit origins.
Example override file:
```yaml
hapi:
fhir:
cors:
allowed_origin:
- "http://localhost:3000"
allowed_headers:
- Origin
- Accept
- Content-Type
- Authorization
- Cache-Control
- If-Match
- If-None-Match
exposed_headers:
- Location
- Content-Location
- ETag
```
### Binary storage configuration
To stream large `Binary` payloads to disk instead of the database, configure the starter with filesystem storage properties:
@@ -475,6 +511,20 @@ jpa:
# Then comment all hibernate.search.backend.*
```
## Docker Health Check
The distroless Docker image includes a built-in health check that verifies the FHIR server is operational by calling the `/fhir/metadata` endpoint and confirming a valid `CapabilityStatement` is returned. It uses a standalone Java class with no external dependencies, making it compatible with the distroless base image which has no shell or utilities like `curl`.
To run the health check inside a running container:
```
docker exec hapi-fhir-jpaserver-start java -cp /app HealthCheck
```
An exit code of `0` indicates the server is healthy. An exit code of `1` indicates a failure, with diagnostic details written to stderr.
To enable periodic health checks, uncomment the `healthcheck` block in `docker-compose.yml`.
## Running hapi-fhir-jpaserver directly from IntelliJ as Spring Boot
Make sure you run with the maven profile called ```boot``` and NOT also ```jetty```. Then you are ready to press debug the project directly without any extra Application Servers.

View File

@@ -8,9 +8,17 @@ services:
SPRING_DATASOURCE_USERNAME: "admin"
SPRING_DATASOURCE_PASSWORD: "admin"
SPRING_DATASOURCE_DRIVER_CLASS_NAME: "org.postgresql.Driver"
SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect
HIBERNATE_DIALECT: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect
ports:
- "8080:8080"
# Uncomment to enable periodic health checks.
# Can also be run manually: docker exec hapi-fhir-jpaserver-start java -cp /app HealthCheck
# healthcheck:
# test: ["CMD", "java", "-cp", "/app", "HealthCheck"]
# interval: 30s
# timeout: 10s
# start_period: 60s
# retries: 3
depends_on:
hapi-fhir-postgres:
condition: service_healthy

View File

@@ -6,7 +6,7 @@
<properties>
<java.version>17</java.version>
<hapi.fhir.jpa.server.starter.revision>1</hapi.fhir.jpa.server.starter.revision>
<clinical-reasoning.version>4.2.0</clinical-reasoning.version>
<clinical-reasoning.version>4.4.0</clinical-reasoning.version>
<!-- Plugins Versions -->
<maven.failsafe.version>3.5.4</maven.failsafe.version>
@@ -35,7 +35,7 @@
<parent>
<groupId>ca.uhn.hapi.fhir</groupId>
<artifactId>hapi-fhir</artifactId>
<version>8.7.13-SNAPSHOT</version>
<version>8.8.0</version>
</parent>
<artifactId>hapi-fhir-jpaserver-starter</artifactId>

View File

@@ -0,0 +1,45 @@
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.regex.Pattern;
public class HealthCheck {
public static void main(String[] args) {
try {
var port = System.getenv().getOrDefault("SERVER_PORT", "8080");
var url = new URL("http://localhost:" + port + "/fhir/metadata");
var conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/fhir+json");
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
var status = conn.getResponseCode();
if (status != 200) {
System.err.println("Health check failed: HTTP " + status);
System.exit(1);
}
var body = new StringBuilder();
try (var reader = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
}
var pattern = Pattern.compile("\"resourceType\"\\s*:\\s*\"CapabilityStatement\"");
if (pattern.matcher(body.toString()).find()) {
System.exit(0);
} else {
System.err.println("Health check failed: CapabilityStatement not found in response");
System.exit(1);
}
} catch (Exception e) {
System.err.println("Health check failed: " + e.getMessage());
System.exit(1);
}
}
}

View File

@@ -867,11 +867,37 @@ public class AppProperties {
}
public static class Cors {
private Boolean allow_Credentials = true;
private static final List<String> DEFAULT_ALLOWED_HEADERS = List.of(
"Origin",
"Accept",
"Content-Type",
"Authorization",
"Cache-Control",
"If-Match",
"If-None-Match",
"x-fhir-starter",
"X-Requested-With",
"Prefer");
private static final List<String> DEFAULT_EXPOSED_HEADERS = List.of(
"Location",
"Content-Location",
"ETag",
"Date",
"Retry-After",
"X-Correlation-Id",
"X-Progress",
"X-Request-Id");
private static final List<String> DEFAULT_ALLOWED_METHODS =
List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD");
private Boolean allow_Credentials = false;
private List<String> allowed_origin = List.of("*");
private List<String> allowed_headers = DEFAULT_ALLOWED_HEADERS;
private List<String> exposed_headers = DEFAULT_EXPOSED_HEADERS;
private List<String> allowed_methods = DEFAULT_ALLOWED_METHODS;
public List<String> getAllowed_origin() {
return allowed_origin;
return defaultIfNull(allowed_origin, List.of("*"));
}
public void setAllowed_origin(List<String> allowed_origin) {
@@ -885,6 +911,30 @@ public class AppProperties {
public void setAllow_Credentials(Boolean allow_Credentials) {
this.allow_Credentials = allow_Credentials;
}
public List<String> getAllowed_headers() {
return defaultIfNull(allowed_headers, DEFAULT_ALLOWED_HEADERS);
}
public void setAllowed_headers(List<String> allowed_headers) {
this.allowed_headers = allowed_headers;
}
public List<String> getExposed_headers() {
return defaultIfNull(exposed_headers, DEFAULT_EXPOSED_HEADERS);
}
public void setExposed_headers(List<String> exposed_headers) {
this.exposed_headers = exposed_headers;
}
public List<String> getAllowed_methods() {
return defaultIfNull(allowed_methods, DEFAULT_ALLOWED_METHODS);
}
public void setAllowed_methods(List<String> allowed_methods) {
this.allowed_methods = allowed_methods;
}
}
public static class Logger {

View File

@@ -7,7 +7,6 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Arrays;
public class ErrorHandling {
@@ -68,30 +67,14 @@ public class ErrorHandling {
public static void setAccessControlHeaders(HttpServletResponse resp, AppProperties myAppProperties) {
if (myAppProperties.getCors() != null) {
if (myAppProperties.getCors().getAllow_Credentials()) {
resp.setHeader(
"Access-Control-Allow-Origin",
myAppProperties.getCors().getAllowed_origin().stream()
.findFirst()
.get());
resp.setHeader(
"Access-Control-Allow-Methods",
String.join(", ", Arrays.asList("GET", "HEAD", "POST", "OPTIONS")));
resp.setHeader(
"Access-Control-Allow-Headers",
String.join(
", ",
Arrays.asList(
"x-fhir-starter",
"Origin",
"Accept",
"X-Requested-With",
"Content-Type",
"Authorization",
"Cache-Control")));
resp.setHeader(
"Access-Control-Expose-Headers",
String.join(", ", Arrays.asList("Location", "Content-Location")));
AppProperties.Cors cors = myAppProperties.getCors();
if (cors.getAllow_Credentials()) {
String allowOrigin =
cors.getAllowed_origin().stream().findFirst().orElse("*");
resp.setHeader("Access-Control-Allow-Origin", allowOrigin);
resp.setHeader("Access-Control-Allow-Methods", String.join(", ", cors.getAllowed_methods()));
resp.setHeader("Access-Control-Allow-Headers", String.join(", ", cors.getAllowed_headers()));
resp.setHeader("Access-Control-Expose-Headers", String.join(", ", cors.getExposed_headers()));
resp.setHeader("Access-Control-Max-Age", "86400");
}
}

View File

@@ -96,7 +96,6 @@ 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.http.HttpHeaders;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.web.cors.CorsConfiguration;
@@ -280,24 +279,17 @@ public class StarterJpaConfig {
// showing a typical setup. You should customize this
// to your specific needs
ourLog.info("CORS is enabled on this server");
AppProperties.Cors corsProperties = appProperties.getCors();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedHeader(HttpHeaders.ORIGIN);
config.addAllowedHeader(HttpHeaders.ACCEPT);
config.addAllowedHeader(HttpHeaders.CONTENT_TYPE);
config.addAllowedHeader(HttpHeaders.AUTHORIZATION);
config.addAllowedHeader(HttpHeaders.CACHE_CONTROL);
config.addAllowedHeader("x-fhir-starter");
config.addAllowedHeader("X-Requested-With");
config.addAllowedHeader("Prefer");
corsProperties.getAllowed_headers().forEach(config::addAllowedHeader);
List<String> allAllowedCORSOrigins = appProperties.getCors().getAllowed_origin();
List<String> allAllowedCORSOrigins = corsProperties.getAllowed_origin();
allAllowedCORSOrigins.forEach(config::addAllowedOriginPattern);
ourLog.info("CORS allows the following origins: {}", String.join(", ", allAllowedCORSOrigins));
config.addExposedHeader("Location");
config.addExposedHeader("Content-Location");
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"));
config.setAllowCredentials(appProperties.getCors().getAllow_Credentials());
corsProperties.getExposed_headers().forEach(config::addExposedHeader);
config.setAllowedMethods(corsProperties.getAllowed_methods());
config.setAllowCredentials(corsProperties.getAllow_Credentials());
// Create the interceptor and register it
return new CorsInterceptor(config);

View File

@@ -338,9 +338,31 @@ hapi:
# K. CORS
# -------------------------------------------------------------------------------
cors:
allow_Credentials: true
# allow_Credentials: false
allowed_origin:
- "*"
# If you enable allow_Credentials=true, use explicit origins instead of "*".
# Optional overrides. If omitted, built-in defaults are used.
# allowed_headers:
# - Origin
# - Accept
# - Content-Type
# - Authorization
# - Cache-Control
# - If-Match
# - If-None-Match
# exposed_headers:
# - Location
# - Content-Location
# - ETag
# allowed_methods:
# - GET
# - POST
# - PUT
# - DELETE
# - OPTIONS
# - PATCH
# - HEAD
# -------------------------------------------------------------------------------
# L. Search Orchestration

View File

@@ -120,7 +120,7 @@ spring:
# 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
dialect: ${HIBERNATE_DIALECT:ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect}
# --- Optional Hibernate DDL & tuning (commented out from source) ---
hbm2ddl:
@@ -368,9 +368,31 @@ hapi:
# K. CORS
# -------------------------------------------------------------------------------
cors:
allow_Credentials: true
# allow_Credentials: false
allowed_origin:
- "*"
# If you enable allow_Credentials=true, use explicit origins instead of "*".
# Optional overrides. If omitted, built-in defaults are used.
# allowed_headers:
# - Origin
# - Accept
# - Content-Type
# - Authorization
# - Cache-Control
# - If-Match
# - If-None-Match
# exposed_headers:
# - Location
# - Content-Location
# - ETag
# allowed_methods:
# - GET
# - POST
# - PUT
# - DELETE
# - OPTIONS
# - PATCH
# - HEAD
# -------------------------------------------------------------------------------
# L. Search Orchestration

View File

@@ -0,0 +1,30 @@
package ca.uhn.fhir.jpa.starter;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AppPropertiesCorsDefaultsTest {
@Test
void defaultCorsHeadersIncludeFhirOptimisticLockingHeaders() {
AppProperties.Cors cors = new AppProperties.Cors();
assertFalse(cors.getAllow_Credentials());
assertTrue(cors.getAllowed_headers().contains("If-Match"));
assertTrue(cors.getExposed_headers().contains("ETag"));
}
@Test
void nullCorsListsFallBackToDefaults() {
AppProperties.Cors cors = new AppProperties.Cors();
cors.setAllowed_headers(null);
cors.setExposed_headers(null);
cors.setAllowed_methods(null);
assertTrue(cors.getAllowed_headers().contains("If-Match"));
assertTrue(cors.getExposed_headers().contains("ETag"));
assertTrue(cors.getAllowed_methods().contains("PATCH"));
}
}