Skip to content

Commit 5ab5c58

Browse files
committed
docs: Add CORS preflight handler design spec
1 parent 669442c commit 5ab5c58

1 file changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# CORS preflight handler
2+
3+
**Date:** 2026-05-25
4+
**Status:** Design — ready for implementation plan
5+
6+
## Problem
7+
8+
Browsers send a CORS preflight (`OPTIONS` with `Origin` and
9+
`Access-Control-Request-Method` headers) before any non-simple
10+
cross-origin request. The library's OpenAPI router dispatches strictly
11+
by `operationId`, and OpenAPI specs typically do not declare `OPTIONS`
12+
operations — so today, every preflight to a service built on this
13+
library ends in `405 Method Not Allowed` and the cross-origin request
14+
never goes through.
15+
16+
We want a ready-to-use `RequestHandler` factory on `Handlers` that
17+
answers preflights correctly and is wired in via the existing
18+
`extraRoute(...)` mechanism, mirroring how `aliveHandler` and
19+
`healthHandler` are exposed.
20+
21+
## Goals
22+
23+
1. Add two overloads on `Handlers`:
24+
- `corsPreflightHandler(List<String> allowedOrigins, List<HttpMethod> allowedMethods, List<String> allowedHeaders, boolean allowCredentials, Duration maxAge)`
25+
- `corsPreflightHandler(Predicate<String> originAllowed, List<HttpMethod> allowedMethods, List<String> allowedHeaders, boolean allowCredentials, Duration maxAge)`
26+
- The list overload delegates to the predicate overload (`origins::contains`).
27+
2. Validate inputs at construction:
28+
- Non-null lists/predicate.
29+
- `allowedMethods` non-empty.
30+
- `maxAge` (if non-null) non-negative and ≤ `Integer.MAX_VALUE` seconds.
31+
3. On request:
32+
- Non-OPTIONS → `405` with `Allow: OPTIONS`.
33+
- OPTIONS with missing `Origin``400` (RFC 7807 problem+json via
34+
existing `BadRequestException` path).
35+
- OPTIONS with missing `Access-Control-Request-Method``400`.
36+
- Origin not allowed → `403` (no CORS headers in response, so the
37+
browser blocks).
38+
- Requested method not in `allowedMethods``403`.
39+
- Requested headers (`Access-Control-Request-Headers`, comma-split,
40+
case-insensitive) include a header not in `allowedHeaders``403`.
41+
- All checks pass → `204 No Content` with `responseLength = -1` and:
42+
- `Access-Control-Allow-Origin: <echoed origin>`
43+
- `Access-Control-Allow-Methods: <comma-joined allowedMethods>`
44+
- `Access-Control-Allow-Headers: <comma-joined allowedHeaders>`
45+
(omitted if `allowedHeaders` empty)
46+
- `Access-Control-Allow-Credentials: true` (only if
47+
`allowCredentials` true)
48+
- `Access-Control-Max-Age: <seconds>` (only if `maxAge` non-null)
49+
- `Vary: Origin` (always)
50+
4. README snippet showing `extraRoute("/api/*", Handlers.corsPreflightHandler(...))`.
51+
52+
## Non-goals
53+
54+
- No `Access-Control-Expose-Headers` support — that header belongs on
55+
the *actual* response, not the preflight, so it is a `ResponseDecorator`
56+
concern. Out of scope; revisit as a separate `corsResponseDecorator`
57+
later if needed.
58+
- No first-class `OpenApiServer.builder().cors(...)` method. The
59+
primitive is a `RequestHandler` factory; wiring goes through the
60+
existing `extraRoute` mechanism (wildcard paths already supported).
61+
- No deriving `Allow-Methods` from the OpenAPI spec. Caller supplies
62+
explicit lists; this keeps the handler self-contained and predictable.
63+
- No interceptor-based auto-application. Caller decides which paths
64+
receive preflight handling.
65+
66+
## Design
67+
68+
### API
69+
70+
```java
71+
public static RequestHandler corsPreflightHandler(
72+
List<String> allowedOrigins,
73+
List<HttpMethod> allowedMethods,
74+
List<String> allowedHeaders,
75+
boolean allowCredentials,
76+
Duration maxAge);
77+
78+
public static RequestHandler corsPreflightHandler(
79+
Predicate<String> originAllowed,
80+
List<HttpMethod> allowedMethods,
81+
List<String> allowedHeaders,
82+
boolean allowCredentials,
83+
Duration maxAge);
84+
```
85+
86+
`HttpMethod`, `RequestHandler`, `Response`, and `BadRequestException`
87+
already exist and are reused as-is. No new public types.
88+
89+
### Internals
90+
91+
A single private static helper inside `Handlers.java` does the
92+
validation switch and assembles the response. Request header reads use
93+
the same `req.headers().firstValue(...)` pattern the existing handlers
94+
use. Header-name comparison for `Access-Control-Request-Headers` is
95+
case-insensitive (HTTP semantics).
96+
97+
The `allowedHeaders` list is normalised to lower-case once at
98+
construction time and stored as an unmodifiable `Set<String>` so the
99+
per-request comparison is O(1).
100+
101+
### Wire example
102+
103+
Request:
104+
105+
```
106+
OPTIONS /api/products HTTP/1.1
107+
Origin: https://app.example.com
108+
Access-Control-Request-Method: POST
109+
Access-Control-Request-Headers: content-type, authorization
110+
```
111+
112+
Response (with `allowCredentials=true`, `maxAge=Duration.ofMinutes(10)`):
113+
114+
```
115+
HTTP/1.1 204 No Content
116+
Access-Control-Allow-Origin: https://app.example.com
117+
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
118+
Access-Control-Allow-Headers: content-type, authorization
119+
Access-Control-Allow-Credentials: true
120+
Access-Control-Max-Age: 600
121+
Vary: Origin
122+
```
123+
124+
## Testing
125+
126+
`HandlersTest.java` gains the following methods (camelCase, AssertJ +
127+
JUnit static imports, curly braces always, `HttpURLConnection`
128+
constants for status codes):
129+
130+
- `corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight`
131+
- `corsPreflightHandlerEchoesOriginAndIncludesVary`
132+
- `corsPreflightHandlerOmitsAllowCredentialsWhenFalse`
133+
- `corsPreflightHandlerOmitsMaxAgeWhenNull`
134+
- `corsPreflightHandlerEmitsMaxAgeInSecondsWhenSet`
135+
- `corsPreflightHandlerOmitsAllowHeadersWhenListEmpty`
136+
- `corsPreflightHandlerRejectsNonOptionsWith405AndAllowOptions`
137+
- `corsPreflightHandlerRejectsMissingOriginWith400`
138+
- `corsPreflightHandlerRejectsMissingRequestMethodWith400`
139+
- `corsPreflightHandlerRejectsDisallowedOriginWith403`
140+
- `corsPreflightHandlerRejectsDisallowedMethodWith403`
141+
- `corsPreflightHandlerRejectsDisallowedHeaderWith403`
142+
- `corsPreflightHandlerMatchesHeadersCaseInsensitively`
143+
- `corsPreflightHandlerListOverloadDelegatesToPredicate`
144+
- Constructor-validation: nulls, empty methods, negative maxAge.
145+
146+
No new integration test — no wiring change to `OpenApiServer`.
147+
148+
## Documentation
149+
150+
Short README section under "Built-in handlers" with one usage snippet:
151+
152+
```java
153+
server = OpenApiServer.builder()
154+
.extraRoute("/api/*", Handlers.corsPreflightHandler(
155+
List.of("https://app.example.com"),
156+
List.of(GET, POST, PUT, DELETE),
157+
List.of("content-type", "authorization"),
158+
true,
159+
Duration.ofMinutes(10)))
160+
.handlers(operations)
161+
.build();
162+
```

0 commit comments

Comments
 (0)