CorsProperties.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.configuration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
/**
* Immutable view of the {@code entrystore.cors*} configuration, consumed by
* {@link EntryStoreCorsConfigurationSource} (for the actual policy), by {@code SecurityConfig} (to
* decide whether to install the CORS filter at all) and by {@code StatusService} (to report it).
*
* <p>Bound through {@code @Value} rather than {@code @ConfigurationProperties} because the legacy
* key layout cannot be expressed as a record: {@code entrystore.cors} is simultaneously a scalar and
* the prefix of {@code entrystore.cors.origins}, which is itself simultaneously a scalar and the
* prefix of {@code entrystore.cors.origins.allow-credentials}. A record component can be one or the
* other, never both, and no component name canonicalises to {@code origins.allow-credentials}.
*
* <p>The canonical constructor is written out in full so the {@code @Value} annotations sit on its
* parameters only. Placing them on the record components instead would propagate them to the backing
* fields as well (they are applicable to {@code FIELD}), and
* {@code AutowiredAnnotationBeanPostProcessor} — which skips annotated record accessors but not
* annotated record fields — would then attempt field injection into a record's final field and fail
* at startup.
*/
@Component
public record CorsProperties(
boolean enabled,
String origins,
String originsAllowCredentials,
String headers,
int maxAge) {
public CorsProperties(
@Value("${entrystore.cors:false}") boolean enabled,
@Value("${entrystore.cors.origins:*}") String origins,
@Value("${entrystore.cors.origins.allow-credentials:}") String originsAllowCredentials,
@Value("${entrystore.cors.headers:}") String headers,
@Value("${entrystore.cors.max-age:-1}") int maxAge) {
this.enabled = enabled;
this.origins = origins;
this.originsAllowCredentials = originsAllowCredentials;
this.headers = headers;
this.maxAge = maxAge;
}
/**
* Origin patterns permitted without credentials. Lower-cased so a pattern configured in mixed
* case still matches; {@code EntryStoreCorsConfigurationSource} lower-cases the request origin
* before matching to complete the pair, because {@code allowedOriginPatterns} compiles to a
* case-sensitive regex.
*/
public List<String> originPatterns() {
return splitAndNormalize(origins);
}
/**
* Origin patterns additionally permitted to send credentials. Empty unless configured — an empty
* list means no origin gets {@code Access-Control-Allow-Credentials: true}.
*/
public List<String> credentialOriginPatterns() {
return splitAndNormalize(originsAllowCredentials);
}
/**
* Headers to both allow on requests and expose on responses. Empty means "not configured", in
* which case neither header list is set — and note that {@code CorsConfiguration} has no
* permissive default there: {@code checkHeaders} returns {@code null} for a preflight carrying
* {@code Access-Control-Request-Headers}, so {@code DefaultCorsProcessor} answers 403. Turning
* CORS on for browser clients that send anything beyond the CORS-safelisted headers (including
* {@code Content-Type: application/json}) therefore requires configuring this.
*/
public List<String> headerList() {
return split(headers);
}
private static List<String> splitAndNormalize(String value) {
return split(value).stream()
.map(pattern -> pattern.toLowerCase(Locale.ROOT))
.toList();
}
private static List<String> split(String value) {
return Arrays.stream(value.split(","))
.map(String::trim)
.filter(part -> !part.isEmpty())
.toList();
}
}