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
This commit is contained in:
Patrick Werner
2026-03-17 09:35:05 +01:00
committed by GitHub
7 changed files with 179 additions and 44 deletions

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:

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

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