|
| 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