feat: enhance CORS configuration with customizable headers and methods
This commit is contained in:
35
README.md
35
README.md
@@ -49,6 +49,41 @@ 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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -867,11 +867,37 @@ public class AppProperties {
|
||||
}
|
||||
|
||||
public static class Cors {
|
||||
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 = true;
|
||||
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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -341,6 +341,27 @@ hapi:
|
||||
allow_Credentials: true
|
||||
allowed_origin:
|
||||
- "*"
|
||||
# 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
|
||||
|
||||
@@ -371,6 +371,27 @@ hapi:
|
||||
allow_Credentials: true
|
||||
allowed_origin:
|
||||
- "*"
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package ca.uhn.fhir.jpa.starter;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class AppPropertiesCorsDefaultsTest {
|
||||
|
||||
@Test
|
||||
void defaultCorsHeadersIncludeFhirOptimisticLockingHeaders() {
|
||||
AppProperties.Cors cors = new AppProperties.Cors();
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user