Skip to content

Commit d561aac

Browse files
committed
refactor: Extract CORS support into Cors class
Move corsPreflightHandler out of Handlers into a dedicated Cors class with the simpler API Cors.preflightHandler(...). The Handlers class is left to truly handler-shaped factories (alive, health, resource, default exception handler). Renames CorsPreflightHandlerTest to CorsTest. README and call sites updated accordingly.
1 parent e74b9e8 commit d561aac

4 files changed

Lines changed: 232 additions & 220 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -947,7 +947,7 @@ Built-in helpers:
947947
classpath resource or filesystem file (content-type inferred from extension; the stream is
948948
opened and closed per request, and the handler owns its lifecycle). Throws
949949
`IllegalArgumentException` at construction if the resource or file is missing.
950-
- `Handlers.corsPreflightHandler(...)` — answers CORS `OPTIONS` preflight requests against
950+
- `Cors.preflightHandler(...)` — answers CORS `OPTIONS` preflight requests against
951951
caller-supplied allowlists. See [CORS preflight](#cors-preflight) below.
952952

953953
### Wildcards in extra routes
@@ -1019,15 +1019,15 @@ header.
10191019

10201020
### CORS preflight
10211021

1022-
`Handlers.corsPreflightHandler(...)` answers `OPTIONS` preflight requests so browsers can
1022+
`Cors.preflightHandler(...)` answers `OPTIONS` preflight requests so browsers can
10231023
perform cross-origin calls against the server. The handler is preflight-only — wire it on a
10241024
wildcard `extraRoute` covering the routes you want to expose to browsers.
10251025

10261026
``` java
10271027
var server = OpenApiServer.builder()
10281028
.spec(spec)
10291029
.handlers(handlers)
1030-
.extraRoute("/api/**", Handlers.corsPreflightHandler(
1030+
.extraRoute("/api/**", Cors.preflightHandler(
10311031
List.of("https://app.example.com"),
10321032
List.of(GET, POST, PUT, DELETE),
10331033
List.of("content-type", "authorization"),
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package com.retailsvc.http;
2+
3+
import static com.retailsvc.http.spec.HttpMethod.OPTIONS;
4+
import static java.net.HttpURLConnection.HTTP_BAD_METHOD;
5+
import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
6+
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
7+
8+
import com.retailsvc.http.spec.HttpMethod;
9+
import java.time.Duration;
10+
import java.util.List;
11+
import java.util.Locale;
12+
import java.util.Objects;
13+
import java.util.Set;
14+
import java.util.function.Predicate;
15+
import java.util.stream.Collectors;
16+
17+
/**
18+
* CORS support. Currently exposes {@link #preflightHandler(List, List, List, boolean, Duration) a
19+
* preflight handler} that answers browser {@code OPTIONS} preflight requests; future cross-origin
20+
* helpers (e.g. a response decorator for {@code Access-Control-Expose-Headers}) will live here too.
21+
*/
22+
public final class Cors {
23+
24+
private static final String ALLOW = "Allow";
25+
26+
private Cors() {}
27+
28+
/**
29+
* Returns a {@link RequestHandler} that answers CORS preflight {@code OPTIONS} requests for any
30+
* path the caller wires it under (typically via {@code
31+
* OpenApiServer.builder().extraRoute("/api/*", Cors.preflightHandler(...))}).
32+
*
33+
* <p>Requests are validated in order: origin against {@code allowedOrigins} (exact match), {@code
34+
* Access-Control-Request-Method} against {@code allowedMethods}, and each header in {@code
35+
* Access-Control-Request-Headers} against {@code allowedHeaders} (case-insensitive). A non-{@code
36+
* OPTIONS} request yields {@code 405} with {@code Allow: OPTIONS}; a missing {@code Origin} or
37+
* {@code Access-Control-Request-Method} header yields {@code 400}; any disallowed origin / method
38+
* / header yields {@code 403} with no CORS headers (the browser then blocks the request).
39+
*
40+
* <p>On success the response is {@code 204 No Content} with {@code Access-Control-Allow-Origin}
41+
* echoing the request's {@code Origin}, the configured method and header allowlists, and {@code
42+
* Vary: Origin} so caches segment by origin. {@code Access-Control-Allow-Credentials} and {@code
43+
* Access-Control-Max-Age} are emitted only when enabled.
44+
*
45+
* @param allowedOrigins exact-match origin allowlist; never {@code null}
46+
* @param allowedMethods non-empty list of methods to advertise in {@code Allow-Methods}
47+
* @param allowedHeaders header allowlist (matched case-insensitively); may be empty (then {@code
48+
* Access-Control-Allow-Headers} is omitted)
49+
* @param allowCredentials whether to emit {@code Access-Control-Allow-Credentials: true}
50+
* @param maxAge {@code Access-Control-Max-Age} value; {@code null} omits the header
51+
*/
52+
public static RequestHandler preflightHandler(
53+
List<String> allowedOrigins,
54+
List<HttpMethod> allowedMethods,
55+
List<String> allowedHeaders,
56+
boolean allowCredentials,
57+
Duration maxAge) {
58+
Objects.requireNonNull(allowedOrigins, "allowedOrigins must not be null");
59+
Set<String> origins = Set.copyOf(allowedOrigins);
60+
return preflightHandler(
61+
origins::contains, allowedMethods, allowedHeaders, allowCredentials, maxAge);
62+
}
63+
64+
/**
65+
* Predicate-based overload of {@link #preflightHandler(List, List, List, boolean, Duration)} for
66+
* callers that need dynamic origin policy (regex, suffix match, config lookup).
67+
*/
68+
public static RequestHandler preflightHandler(
69+
Predicate<String> originAllowed,
70+
List<HttpMethod> allowedMethods,
71+
List<String> allowedHeaders,
72+
boolean allowCredentials,
73+
Duration maxAge) {
74+
Objects.requireNonNull(originAllowed, "originAllowed must not be null");
75+
Objects.requireNonNull(allowedMethods, "allowedMethods must not be null");
76+
Objects.requireNonNull(allowedHeaders, "allowedHeaders must not be null");
77+
if (allowedMethods.isEmpty()) {
78+
throw new IllegalArgumentException("allowedMethods must not be empty");
79+
}
80+
if (maxAge != null && (maxAge.isNegative() || maxAge.getSeconds() > Integer.MAX_VALUE)) {
81+
throw new IllegalArgumentException(
82+
"maxAge must be non-negative and fit in an int number of seconds, got " + maxAge);
83+
}
84+
85+
String allowMethodsHeader =
86+
allowedMethods.stream().map(Enum::name).collect(Collectors.joining(", "));
87+
String allowHeadersHeader = String.join(", ", allowedHeaders);
88+
Set<String> headerAllowlistLower =
89+
allowedHeaders.stream()
90+
.map(h -> h.toLowerCase(Locale.ROOT))
91+
.collect(Collectors.toUnmodifiableSet());
92+
String maxAgeHeader = maxAge == null ? null : Long.toString(maxAge.getSeconds());
93+
boolean emitAllowHeaders = !allowedHeaders.isEmpty();
94+
95+
return req -> {
96+
if (req.method() != OPTIONS) {
97+
return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "OPTIONS");
98+
}
99+
String origin = requireHeader(req, "Origin");
100+
String requestMethod = requireHeader(req, "Access-Control-Request-Method");
101+
if (!isPreflightAllowed(
102+
req, origin, requestMethod, originAllowed, allowedMethods, headerAllowlistLower)) {
103+
return Response.status(HTTP_FORBIDDEN);
104+
}
105+
return buildPreflightSuccess(
106+
origin,
107+
allowMethodsHeader,
108+
allowHeadersHeader,
109+
emitAllowHeaders,
110+
allowCredentials,
111+
maxAgeHeader);
112+
};
113+
}
114+
115+
private static String requireHeader(Request req, String name) {
116+
return req.header(name)
117+
.orElseThrow(
118+
() -> new BadRequestException("CORS preflight is missing the " + name + " header"));
119+
}
120+
121+
private static boolean isPreflightAllowed(
122+
Request req,
123+
String origin,
124+
String requestMethod,
125+
Predicate<String> originAllowed,
126+
List<HttpMethod> allowedMethods,
127+
Set<String> headerAllowlistLower) {
128+
if (!originAllowed.test(origin)) {
129+
return false;
130+
}
131+
HttpMethod parsed = parseMethodOrNull(requestMethod);
132+
if (parsed == null || !allowedMethods.contains(parsed)) {
133+
return false;
134+
}
135+
return requestedHeadersAllowed(req, headerAllowlistLower);
136+
}
137+
138+
private static HttpMethod parseMethodOrNull(String s) {
139+
try {
140+
return HttpMethod.parse(s);
141+
} catch (IllegalArgumentException _) {
142+
// Unknown method token — treated as disallowed by the caller.
143+
return null;
144+
}
145+
}
146+
147+
private static boolean requestedHeadersAllowed(Request req, Set<String> allowedLower) {
148+
String requested = req.header("Access-Control-Request-Headers").orElse("");
149+
for (String raw : requested.split(",")) {
150+
String h = raw.trim().toLowerCase(Locale.ROOT);
151+
if (h.isEmpty()) {
152+
continue;
153+
}
154+
if (!allowedLower.contains(h)) {
155+
return false;
156+
}
157+
}
158+
return true;
159+
}
160+
161+
private static Response buildPreflightSuccess(
162+
String origin,
163+
String allowMethodsHeader,
164+
String allowHeadersHeader,
165+
boolean emitAllowHeaders,
166+
boolean allowCredentials,
167+
String maxAgeHeader) {
168+
Response resp =
169+
Response.status(HTTP_NO_CONTENT)
170+
.withHeader("Access-Control-Allow-Origin", origin)
171+
.withHeader("Access-Control-Allow-Methods", allowMethodsHeader)
172+
.withHeader("Vary", "Origin");
173+
if (emitAllowHeaders) {
174+
resp = resp.withHeader("Access-Control-Allow-Headers", allowHeadersHeader);
175+
}
176+
if (allowCredentials) {
177+
resp = resp.withHeader("Access-Control-Allow-Credentials", "true");
178+
}
179+
if (maxAgeHeader != null) {
180+
resp = resp.withHeader("Access-Control-Max-Age", maxAgeHeader);
181+
}
182+
return resp;
183+
}
184+
}

0 commit comments

Comments
 (0)