From 5ab5c58fc0d8fe81b5c008bb76a67667d10b88f9 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:02:19 +0200 Subject: [PATCH 01/12] docs: Add CORS preflight handler design spec --- ...026-05-25-cors-preflight-handler-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-cors-preflight-handler-design.md diff --git a/docs/superpowers/specs/2026-05-25-cors-preflight-handler-design.md b/docs/superpowers/specs/2026-05-25-cors-preflight-handler-design.md new file mode 100644 index 0000000..05e9281 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-cors-preflight-handler-design.md @@ -0,0 +1,162 @@ +# CORS preflight handler + +**Date:** 2026-05-25 +**Status:** Design — ready for implementation plan + +## Problem + +Browsers send a CORS preflight (`OPTIONS` with `Origin` and +`Access-Control-Request-Method` headers) before any non-simple +cross-origin request. The library's OpenAPI router dispatches strictly +by `operationId`, and OpenAPI specs typically do not declare `OPTIONS` +operations — so today, every preflight to a service built on this +library ends in `405 Method Not Allowed` and the cross-origin request +never goes through. + +We want a ready-to-use `RequestHandler` factory on `Handlers` that +answers preflights correctly and is wired in via the existing +`extraRoute(...)` mechanism, mirroring how `aliveHandler` and +`healthHandler` are exposed. + +## Goals + +1. Add two overloads on `Handlers`: + - `corsPreflightHandler(List allowedOrigins, List allowedMethods, List allowedHeaders, boolean allowCredentials, Duration maxAge)` + - `corsPreflightHandler(Predicate originAllowed, List allowedMethods, List allowedHeaders, boolean allowCredentials, Duration maxAge)` + - The list overload delegates to the predicate overload (`origins::contains`). +2. Validate inputs at construction: + - Non-null lists/predicate. + - `allowedMethods` non-empty. + - `maxAge` (if non-null) non-negative and ≤ `Integer.MAX_VALUE` seconds. +3. On request: + - Non-OPTIONS → `405` with `Allow: OPTIONS`. + - OPTIONS with missing `Origin` → `400` (RFC 7807 problem+json via + existing `BadRequestException` path). + - OPTIONS with missing `Access-Control-Request-Method` → `400`. + - Origin not allowed → `403` (no CORS headers in response, so the + browser blocks). + - Requested method not in `allowedMethods` → `403`. + - Requested headers (`Access-Control-Request-Headers`, comma-split, + case-insensitive) include a header not in `allowedHeaders` → `403`. + - All checks pass → `204 No Content` with `responseLength = -1` and: + - `Access-Control-Allow-Origin: ` + - `Access-Control-Allow-Methods: ` + - `Access-Control-Allow-Headers: ` + (omitted if `allowedHeaders` empty) + - `Access-Control-Allow-Credentials: true` (only if + `allowCredentials` true) + - `Access-Control-Max-Age: ` (only if `maxAge` non-null) + - `Vary: Origin` (always) +4. README snippet showing `extraRoute("/api/*", Handlers.corsPreflightHandler(...))`. + +## Non-goals + +- No `Access-Control-Expose-Headers` support — that header belongs on + the *actual* response, not the preflight, so it is a `ResponseDecorator` + concern. Out of scope; revisit as a separate `corsResponseDecorator` + later if needed. +- No first-class `OpenApiServer.builder().cors(...)` method. The + primitive is a `RequestHandler` factory; wiring goes through the + existing `extraRoute` mechanism (wildcard paths already supported). +- No deriving `Allow-Methods` from the OpenAPI spec. Caller supplies + explicit lists; this keeps the handler self-contained and predictable. +- No interceptor-based auto-application. Caller decides which paths + receive preflight handling. + +## Design + +### API + +```java +public static RequestHandler corsPreflightHandler( + List allowedOrigins, + List allowedMethods, + List allowedHeaders, + boolean allowCredentials, + Duration maxAge); + +public static RequestHandler corsPreflightHandler( + Predicate originAllowed, + List allowedMethods, + List allowedHeaders, + boolean allowCredentials, + Duration maxAge); +``` + +`HttpMethod`, `RequestHandler`, `Response`, and `BadRequestException` +already exist and are reused as-is. No new public types. + +### Internals + +A single private static helper inside `Handlers.java` does the +validation switch and assembles the response. Request header reads use +the same `req.headers().firstValue(...)` pattern the existing handlers +use. Header-name comparison for `Access-Control-Request-Headers` is +case-insensitive (HTTP semantics). + +The `allowedHeaders` list is normalised to lower-case once at +construction time and stored as an unmodifiable `Set` so the +per-request comparison is O(1). + +### Wire example + +Request: + +``` +OPTIONS /api/products HTTP/1.1 +Origin: https://app.example.com +Access-Control-Request-Method: POST +Access-Control-Request-Headers: content-type, authorization +``` + +Response (with `allowCredentials=true`, `maxAge=Duration.ofMinutes(10)`): + +``` +HTTP/1.1 204 No Content +Access-Control-Allow-Origin: https://app.example.com +Access-Control-Allow-Methods: GET, POST, PUT, DELETE +Access-Control-Allow-Headers: content-type, authorization +Access-Control-Allow-Credentials: true +Access-Control-Max-Age: 600 +Vary: Origin +``` + +## Testing + +`HandlersTest.java` gains the following methods (camelCase, AssertJ + +JUnit static imports, curly braces always, `HttpURLConnection` +constants for status codes): + +- `corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight` +- `corsPreflightHandlerEchoesOriginAndIncludesVary` +- `corsPreflightHandlerOmitsAllowCredentialsWhenFalse` +- `corsPreflightHandlerOmitsMaxAgeWhenNull` +- `corsPreflightHandlerEmitsMaxAgeInSecondsWhenSet` +- `corsPreflightHandlerOmitsAllowHeadersWhenListEmpty` +- `corsPreflightHandlerRejectsNonOptionsWith405AndAllowOptions` +- `corsPreflightHandlerRejectsMissingOriginWith400` +- `corsPreflightHandlerRejectsMissingRequestMethodWith400` +- `corsPreflightHandlerRejectsDisallowedOriginWith403` +- `corsPreflightHandlerRejectsDisallowedMethodWith403` +- `corsPreflightHandlerRejectsDisallowedHeaderWith403` +- `corsPreflightHandlerMatchesHeadersCaseInsensitively` +- `corsPreflightHandlerListOverloadDelegatesToPredicate` +- Constructor-validation: nulls, empty methods, negative maxAge. + +No new integration test — no wiring change to `OpenApiServer`. + +## Documentation + +Short README section under "Built-in handlers" with one usage snippet: + +```java +server = OpenApiServer.builder() + .extraRoute("/api/*", Handlers.corsPreflightHandler( + List.of("https://app.example.com"), + List.of(GET, POST, PUT, DELETE), + List.of("content-type", "authorization"), + true, + Duration.ofMinutes(10))) + .handlers(operations) + .build(); +``` From 4b5d642e6c21003f7aebe616b00da693942c38fe Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:06:33 +0200 Subject: [PATCH 02/12] docs: Add CORS preflight handler implementation plan --- .../2026-05-25-cors-preflight-handler.md | 721 ++++++++++++++++++ 1 file changed, 721 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-cors-preflight-handler.md diff --git a/docs/superpowers/plans/2026-05-25-cors-preflight-handler.md b/docs/superpowers/plans/2026-05-25-cors-preflight-handler.md new file mode 100644 index 0000000..315cc01 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-cors-preflight-handler.md @@ -0,0 +1,721 @@ +# CORS Preflight Handler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `Handlers.corsPreflightHandler(...)` (two overloads — `List` of allowed origins, and `Predicate`) that answers CORS preflight `OPTIONS` requests with correct CORS response headers and `204 No Content`, validating origin / requested method / requested headers against caller-supplied allowlists. + +**Architecture:** Adds two public static factories on the existing `Handlers` class plus one private static helper that builds the actual `RequestHandler`. No new public types. No changes to `OpenApiServer` — callers wire the handler with the existing `extraRoute("/path/*", ...)`. The list overload delegates to the predicate overload. + +**Tech Stack:** Java 25, JUnit 5, AssertJ, Mockito (not needed here). `java.net.HttpURLConnection` constants for status codes. `java.time.Duration` for max-age. Existing `Request` / `Response` / `BadRequestException` types. + +**Reference spec:** [docs/superpowers/specs/2026-05-25-cors-preflight-handler-design.md](../specs/2026-05-25-cors-preflight-handler-design.md) + +--- + +## File Structure + +- **Modify:** `src/main/java/com/retailsvc/http/Handlers.java` — add two `corsPreflightHandler(...)` overloads and a private static helper that assembles the `RequestHandler`. +- **Create:** `src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java` — dedicated test class (the existing `HandlersTest.java` covers alive/resource handlers; `HealthHandlerTest.java` is a separate file — follow that precedent for the new handler). +- **Modify:** `README.md` — add a short subsection under the "Built-in handlers" area showing the wiring example. + +--- + +## Conventions enforced for every code/test step + +These are non-negotiable per project memory — apply in every step where you write code: + +- Use `HttpURLConnection.HTTP_*` constants, never magic numbers (e.g. `HTTP_NO_CONTENT`, `HTTP_BAD_REQUEST`, `HTTP_FORBIDDEN`, `HTTP_BAD_METHOD`). +- Test method names are camelCase only (never underscore-separated). +- Static-import the test DSLs: `org.assertj.core.api.Assertions.assertThat`, `assertThatThrownBy`, `org.junit.jupiter.api.Assertions.*` if used, and the `HttpMethod` enum constants (`GET`, `POST`, `OPTIONS`, etc.). +- No inline fully-qualified type names; always add `import` statements. +- Always use curly braces around `if`/`else`/`for` bodies, even single-statement. +- Empty-body responses use `responseLength = -1`. In this code that means: rely on `Response.status(204)` / `Response.empty()` and never write a zero-length body. +- Code comments explain *intent* only — never mention Sonar / Javadoc / tooling. +- Don't call the project a "framework"; refer to it as "the library" / "the server". + +--- + +## Tasks + +### Task 1: Test the happy-path preflight returns 204 with all expected CORS headers + +**Files:** +- Test: `src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java` + +- [ ] **Step 1: Create the test class skeleton with a happy-path test** + +Create `src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java`: + +```java +package com.retailsvc.http; + +import static com.retailsvc.http.spec.HttpMethod.DELETE; +import static com.retailsvc.http.spec.HttpMethod.GET; +import static com.retailsvc.http.spec.HttpMethod.OPTIONS; +import static com.retailsvc.http.spec.HttpMethod.POST; +import static com.retailsvc.http.spec.HttpMethod.PUT; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.retailsvc.http.spec.HttpMethod; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +class CorsPreflightHandlerTest { + + private static final List METHODS = List.of(GET, POST, PUT, DELETE); + private static final List HEADERS = List.of("content-type", "authorization"); + private static final List ORIGINS = List.of("https://app.example.com"); + + private static Request preflight(String origin, String requestMethod, String requestHeaders) { + UnaryOperator lookup = + name -> + switch (name.toLowerCase(java.util.Locale.ROOT)) { + case "origin" -> origin; + case "access-control-request-method" -> requestMethod; + case "access-control-request-headers" -> requestHeaders; + default -> null; + }; + return new Request( + new byte[0], null, null, null, Map.of(), null, lookup, Map.of(), OPTIONS); + } + + private static Request bare(HttpMethod method) { + return new Request( + new byte[0], null, null, null, Map.of(), null, n -> null, Map.of(), method); + } + + @Test + void corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight() { + RequestHandler handler = + Handlers.corsPreflightHandler( + ORIGINS, METHODS, HEADERS, true, Duration.ofMinutes(10)); + + Response resp = + handler.handle( + preflight("https://app.example.com", "POST", "content-type, authorization")); + + assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); + assertThat(resp.body()).isNull(); + assertThat(resp.headers()) + .containsEntry("Access-Control-Allow-Origin", "https://app.example.com") + .containsEntry("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") + .containsEntry("Access-Control-Allow-Headers", "content-type, authorization") + .containsEntry("Access-Control-Allow-Credentials", "true") + .containsEntry("Access-Control-Max-Age", "600") + .containsEntry("Vary", "Origin"); + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails to compile** + +Run: `mvn test -Dtest=CorsPreflightHandlerTest#corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight` + +Expected: compilation failure — `cannot find symbol: method corsPreflightHandler` on `Handlers`. + +--- + +### Task 2: Implement `corsPreflightHandler` to make the happy-path test pass + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/Handlers.java` + +- [ ] **Step 1: Add imports to `Handlers.java`** + +Edit the import block at the top of `src/main/java/com/retailsvc/http/Handlers.java` to add: + +```java +import static com.retailsvc.http.spec.HttpMethod.OPTIONS; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; + +import com.retailsvc.http.spec.HttpMethod; +import java.time.Duration; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; // already present, do not duplicate +``` + +(Merge with existing imports; do not duplicate `Collectors`. Keep imports alphabetised within their `import` / `import static` groups — Google Java Formatter enforces this on commit.) + +- [ ] **Step 2: Add the two public factories and the private helper to `Handlers.java`** + +Append the following members to the `Handlers` class (after `securityHeadersDecorator`, before `defaultExceptionHandler` to keep the "decorators / handlers / handlers" grouping): + +```java + /** + * Returns a {@link RequestHandler} that answers CORS preflight {@code OPTIONS} requests for any + * path the caller wires it under (typically via {@code OpenApiServer.builder().extraRoute("/api/*", + * Handlers.corsPreflightHandler(...))}). + * + *

Requests are validated in order: origin against {@code allowedOrigins} (exact match), + * {@code Access-Control-Request-Method} against {@code allowedMethods}, and each header in + * {@code Access-Control-Request-Headers} against {@code allowedHeaders} (case-insensitive). A + * non-{@code OPTIONS} request yields {@code 405} with {@code Allow: OPTIONS}; a missing + * {@code Origin} or {@code Access-Control-Request-Method} header yields {@code 400}; any + * disallowed origin / method / header yields {@code 403} with no CORS headers (the browser then + * blocks the request). + * + *

On success the response is {@code 204 No Content} with {@code Access-Control-Allow-Origin} + * echoing the request's {@code Origin}, the configured method and header allowlists, and + * {@code Vary: Origin} so caches segment by origin. {@code Access-Control-Allow-Credentials} and + * {@code Access-Control-Max-Age} are emitted only when enabled. + * + * @param allowedOrigins exact-match origin allowlist; never {@code null} + * @param allowedMethods non-empty list of methods to advertise in {@code Allow-Methods} + * @param allowedHeaders header allowlist (matched case-insensitively); may be empty (then + * {@code Access-Control-Allow-Headers} is omitted) + * @param allowCredentials whether to emit {@code Access-Control-Allow-Credentials: true} + * @param maxAge {@code Access-Control-Max-Age} value; {@code null} omits the header + */ + public static RequestHandler corsPreflightHandler( + List allowedOrigins, + List allowedMethods, + List allowedHeaders, + boolean allowCredentials, + Duration maxAge) { + Objects.requireNonNull(allowedOrigins, "allowedOrigins must not be null"); + Set origins = Set.copyOf(allowedOrigins); + return corsPreflightHandler( + origins::contains, allowedMethods, allowedHeaders, allowCredentials, maxAge); + } + + /** + * Predicate-based overload of {@link #corsPreflightHandler(List, List, List, boolean, Duration)} + * for callers that need dynamic origin policy (regex, suffix match, config lookup). + */ + public static RequestHandler corsPreflightHandler( + Predicate originAllowed, + List allowedMethods, + List allowedHeaders, + boolean allowCredentials, + Duration maxAge) { + Objects.requireNonNull(originAllowed, "originAllowed must not be null"); + Objects.requireNonNull(allowedMethods, "allowedMethods must not be null"); + Objects.requireNonNull(allowedHeaders, "allowedHeaders must not be null"); + if (allowedMethods.isEmpty()) { + throw new IllegalArgumentException("allowedMethods must not be empty"); + } + if (maxAge != null && (maxAge.isNegative() || maxAge.getSeconds() > Integer.MAX_VALUE)) { + throw new IllegalArgumentException( + "maxAge must be non-negative and fit in an int number of seconds, got " + maxAge); + } + + String allowMethodsHeader = + allowedMethods.stream().map(Enum::name).collect(Collectors.joining(", ")); + String allowHeadersHeader = String.join(", ", allowedHeaders); + Set headerAllowlistLower = + allowedHeaders.stream() + .map(h -> h.toLowerCase(Locale.ROOT)) + .collect(Collectors.toUnmodifiableSet()); + String maxAgeHeader = maxAge == null ? null : Long.toString(maxAge.getSeconds()); + + return req -> { + if (req.method() != OPTIONS) { + return Response.status(HTTP_BAD_METHOD).withHeader("Allow", "OPTIONS"); + } + String origin = req.header("Origin").orElse(null); + if (origin == null) { + throw new BadRequestException("CORS preflight is missing the Origin header"); + } + String requestMethod = req.header("Access-Control-Request-Method").orElse(null); + if (requestMethod == null) { + throw new BadRequestException( + "CORS preflight is missing the Access-Control-Request-Method header"); + } + if (!originAllowed.test(origin)) { + return Response.status(HTTP_FORBIDDEN); + } + HttpMethod parsedMethod; + try { + parsedMethod = HttpMethod.parse(requestMethod); + } catch (IllegalArgumentException e) { + return Response.status(HTTP_FORBIDDEN); + } + if (!allowedMethods.contains(parsedMethod)) { + return Response.status(HTTP_FORBIDDEN); + } + String requestedHeaders = req.header("Access-Control-Request-Headers").orElse(""); + for (String raw : requestedHeaders.split(",")) { + String h = raw.trim().toLowerCase(Locale.ROOT); + if (h.isEmpty()) { + continue; + } + if (!headerAllowlistLower.contains(h)) { + return Response.status(HTTP_FORBIDDEN); + } + } + + Response resp = + Response.status(HTTP_NO_CONTENT) + .withHeader("Access-Control-Allow-Origin", origin) + .withHeader("Access-Control-Allow-Methods", allowMethodsHeader) + .withHeader("Vary", "Origin"); + if (!allowedHeaders.isEmpty()) { + resp = resp.withHeader("Access-Control-Allow-Headers", allowHeadersHeader); + } + if (allowCredentials) { + resp = resp.withHeader("Access-Control-Allow-Credentials", "true"); + } + if (maxAgeHeader != null) { + resp = resp.withHeader("Access-Control-Max-Age", maxAgeHeader); + } + return resp; + }; + } +``` + +Notes for the implementer: +- `Response.status(int)` and `Response.withHeader(String, String)` already exist; do not modify `Response.java`. +- `BadRequestException(String)` is the existing 400 ctor; do not pass a status. +- `HTTP_BAD_METHOD` is already imported at the top of `Handlers.java`. + +- [ ] **Step 3: Run the happy-path test and verify it passes** + +Run: `mvn test -Dtest=CorsPreflightHandlerTest#corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight` + +Expected: PASS, 1 test. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/retailsvc/http/Handlers.java \ + src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +git commit -m "feat: Add CORS preflight handler" +``` + +--- + +### Task 3: Test header-omission cases (no credentials, no max-age, no allow-headers) + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java` + +- [ ] **Step 1: Add three tests verifying optional headers are omitted when configured off** + +Append inside the test class: + +```java + @Test + void corsPreflightHandlerOmitsAllowCredentialsWhenFalse() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, Duration.ofMinutes(10)); + + Response resp = + handler.handle(preflight("https://app.example.com", "POST", "content-type")); + + assertThat(resp.headers()).doesNotContainKey("Access-Control-Allow-Credentials"); + } + + @Test + void corsPreflightHandlerOmitsMaxAgeWhenNull() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, true, null); + + Response resp = + handler.handle(preflight("https://app.example.com", "POST", "content-type")); + + assertThat(resp.headers()).doesNotContainKey("Access-Control-Max-Age"); + } + + @Test + void corsPreflightHandlerEmitsMaxAgeInSecondsWhenSet() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, Duration.ofSeconds(75)); + + Response resp = + handler.handle(preflight("https://app.example.com", "POST", "content-type")); + + assertThat(resp.headers()).containsEntry("Access-Control-Max-Age", "75"); + } + + @Test + void corsPreflightHandlerOmitsAllowHeadersWhenListEmpty() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, List.of(), false, null); + + Response resp = handler.handle(preflight("https://app.example.com", "POST", "")); + + assertThat(resp.headers()).doesNotContainKey("Access-Control-Allow-Headers"); + assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); + } +``` + +- [ ] **Step 2: Run the new tests and verify all pass** + +Run: `mvn test -Dtest=CorsPreflightHandlerTest` + +Expected: PASS, 5 tests. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +git commit -m "test: Cover CORS preflight optional header omission" +``` + +--- + +### Task 4: Test rejection paths (405 on non-OPTIONS, 400 on missing Origin / request-method, 403 on disallowed origin / method / header) + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java` + +- [ ] **Step 1: Add rejection tests** + +Append inside the test class: + +```java + @Test + void corsPreflightHandlerRejectsNonOptionsWith405AndAllowOptions() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + Response resp = handler.handle(bare(GET)); + + assertThat(resp.status()).isEqualTo(HTTP_BAD_METHOD); + assertThat(resp.headers()).containsEntry("Allow", "OPTIONS"); + } + + @Test + void corsPreflightHandlerRejectsMissingOriginWith400() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + assertThatThrownBy(() -> handler.handle(preflight(null, "POST", "content-type"))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Origin"); + } + + @Test + void corsPreflightHandlerRejectsMissingRequestMethodWith400() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + assertThatThrownBy( + () -> handler.handle(preflight("https://app.example.com", null, "content-type"))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Access-Control-Request-Method"); + } + + @Test + void corsPreflightHandlerRejectsDisallowedOriginWith403() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + Response resp = + handler.handle(preflight("https://evil.example.com", "POST", "content-type")); + + assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); + assertThat(resp.headers()).doesNotContainKey("Access-Control-Allow-Origin"); + } + + @Test + void corsPreflightHandlerRejectsDisallowedMethodWith403() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, List.of(GET), HEADERS, false, null); + + Response resp = + handler.handle(preflight("https://app.example.com", "DELETE", "content-type")); + + assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); + } + + @Test + void corsPreflightHandlerRejectsDisallowedHeaderWith403() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, List.of("content-type"), false, null); + + Response resp = + handler.handle(preflight("https://app.example.com", "POST", "x-secret")); + + assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); + } + + @Test + void corsPreflightHandlerRejectsUnknownMethodTokenWith403() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + Response resp = + handler.handle(preflight("https://app.example.com", "BOGUS", "content-type")); + + assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); + } +``` + +- [ ] **Step 2: Run the test class and verify all pass** + +Run: `mvn test -Dtest=CorsPreflightHandlerTest` + +Expected: PASS, 12 tests. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +git commit -m "test: Cover CORS preflight rejection paths" +``` + +--- + +### Task 5: Test case-insensitive header matching, Vary header always present, list-overload delegation + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java` + +- [ ] **Step 1: Add three tests** + +Append: + +```java + @Test + void corsPreflightHandlerMatchesHeadersCaseInsensitively() { + RequestHandler handler = + Handlers.corsPreflightHandler( + ORIGINS, METHODS, List.of("Content-Type", "Authorization"), false, null); + + Response resp = + handler.handle( + preflight("https://app.example.com", "POST", "CONTENT-TYPE, authorization")); + + assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); + } + + @Test + void corsPreflightHandlerEchoesOriginAndIncludesVary() { + Predicate anyExampleOrigin = o -> o.endsWith(".example.com"); + RequestHandler handler = + Handlers.corsPreflightHandler(anyExampleOrigin, METHODS, HEADERS, false, null); + + Response resp = + handler.handle(preflight("https://tenant-7.example.com", "POST", "content-type")); + + assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); + assertThat(resp.headers()) + .containsEntry("Access-Control-Allow-Origin", "https://tenant-7.example.com") + .containsEntry("Vary", "Origin"); + } + + @Test + void corsPreflightHandlerListOverloadDelegatesToPredicateBehaviour() { + RequestHandler list = + Handlers.corsPreflightHandler( + List.of("https://a.example.com", "https://b.example.com"), + METHODS, + HEADERS, + false, + null); + + Response allowed = list.handle(preflight("https://b.example.com", "POST", "content-type")); + Response denied = list.handle(preflight("https://c.example.com", "POST", "content-type")); + + assertThat(allowed.status()).isEqualTo(HTTP_NO_CONTENT); + assertThat(denied.status()).isEqualTo(HTTP_FORBIDDEN); + } +``` + +- [ ] **Step 2: Run all tests in the class** + +Run: `mvn test -Dtest=CorsPreflightHandlerTest` + +Expected: PASS, 15 tests. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +git commit -m "test: Cover CORS preflight case-insensitivity, Vary, list overload" +``` + +--- + +### Task 6: Test constructor-validation (nulls, empty methods, negative maxAge, maxAge overflow) + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java` + +- [ ] **Step 1: Add validation tests** + +Append: + +```java + @Test + void corsPreflightHandlerRejectsNullOriginList() { + assertThatThrownBy( + () -> + Handlers.corsPreflightHandler( + (List) null, METHODS, HEADERS, false, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("allowedOrigins"); + } + + @Test + void corsPreflightHandlerRejectsNullOriginPredicate() { + assertThatThrownBy( + () -> + Handlers.corsPreflightHandler( + (Predicate) null, METHODS, HEADERS, false, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("originAllowed"); + } + + @Test + void corsPreflightHandlerRejectsNullMethods() { + assertThatThrownBy( + () -> Handlers.corsPreflightHandler(ORIGINS, null, HEADERS, false, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("allowedMethods"); + } + + @Test + void corsPreflightHandlerRejectsEmptyMethods() { + assertThatThrownBy( + () -> Handlers.corsPreflightHandler(ORIGINS, List.of(), HEADERS, false, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allowedMethods"); + } + + @Test + void corsPreflightHandlerRejectsNullHeaders() { + assertThatThrownBy( + () -> Handlers.corsPreflightHandler(ORIGINS, METHODS, null, false, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("allowedHeaders"); + } + + @Test + void corsPreflightHandlerRejectsNegativeMaxAge() { + assertThatThrownBy( + () -> + Handlers.corsPreflightHandler( + ORIGINS, METHODS, HEADERS, false, Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxAge"); + } + + @Test + void corsPreflightHandlerRejectsOverflowingMaxAge() { + Duration tooBig = Duration.ofSeconds((long) Integer.MAX_VALUE + 1); + assertThatThrownBy( + () -> Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, tooBig)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxAge"); + } +``` + +- [ ] **Step 2: Run all tests in the class** + +Run: `mvn test -Dtest=CorsPreflightHandlerTest` + +Expected: PASS, 22 tests. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +git commit -m "test: Cover CORS preflight constructor validation" +``` + +--- + +### Task 7: Document the handler in README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Find the existing "Built-in handlers" / Handlers section** + +Run: `grep -n "aliveHandler\|healthHandler\|securityHeadersDecorator" README.md` + +Identify the surrounding section heading (probably `## Built-in handlers` or similar). The new subsection follows the same heading depth and prose style as the existing entries. + +- [ ] **Step 2: Add a subsection after the existing handler entries** + +Insert this subsection at the end of the Built-in handlers section (mirror the heading level used by `aliveHandler` and `healthHandler`): + +```markdown +### CORS preflight handler + +Answers `OPTIONS` preflight requests so browsers can perform cross-origin +calls against your service. The handler is preflight-only; wire it on a +wildcard `extraRoute` path that covers the routes you want to expose to +browsers. + +```java +server = OpenApiServer.builder() + .extraRoute("/api/*", Handlers.corsPreflightHandler( + List.of("https://app.example.com"), + List.of(GET, POST, PUT, DELETE), + List.of("content-type", "authorization"), + true, // allowCredentials + Duration.ofMinutes(10))) // Access-Control-Max-Age + .handlers(operations) + .build(); +``` + +For dynamic origin policy (regex, tenant lookup), pass a +`Predicate` instead of a `List`. +``` + +- [ ] **Step 3: Verify the file still renders sensibly** + +Run: `grep -n "corsPreflightHandler" README.md` + +Expected: the new code block appears in exactly the spot you inserted it; no other lines reference the new handler. + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: Document CORS preflight handler in README" +``` + +--- + +### Task 8: Run the full build and verify clean baseline + +**Files:** (none) + +- [ ] **Step 1: Run the full unit test suite** + +Run: `mvn test` + +Expected: BUILD SUCCESS; the `CorsPreflightHandlerTest` reports 22 tests, 0 failures; no other tests regressed. + +- [ ] **Step 2: Run integration tests** + +Run: `mvn verify` + +Expected: BUILD SUCCESS; coverage report appears at `target/site/jacoco/`. + +- [ ] **Step 3: Verify SonarLint MCP is clean on the touched files** + +Run SonarLint MCP analysis (the analyse-files tool) on: +- `src/main/java/com/retailsvc/http/Handlers.java` +- `src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java` + +Expected: zero new issues introduced by this branch. + +Note: SonarLint MCP cannot see files inside `.claude/worktrees/` — its `/workspace` mount points at the main repo. If you hit `not_found` for the worktree paths, accept the CI Sonar scan as the source of truth for the branch. + +- [ ] **Step 4: Final summary** + +The branch `feat/cors-handler` now contains: +- Two new public factory overloads on `Handlers` plus a private helper. +- A new `CorsPreflightHandlerTest` with 22 tests covering happy path, optional-header omission, every rejection branch, case-insensitive header matching, list-overload delegation, and constructor validation. +- A README subsection wiring the handler via `extraRoute`. + +Ready to open a PR against `master`. From ad11362d192d48284a814bd1e0d60dceb9d86a51 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:19:31 +0200 Subject: [PATCH 03/12] feat: Add CORS preflight handler --- .../java/com/retailsvc/http/Handlers.java | 128 ++++++++++++++++++ .../http/CorsPreflightHandlerTest.java | 58 ++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index b3cc8a2..b6afffe 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -2,9 +2,12 @@ import static com.retailsvc.http.spec.HttpMethod.GET; import static com.retailsvc.http.spec.HttpMethod.HEAD; +import static com.retailsvc.http.spec.HttpMethod.OPTIONS; import static java.net.HttpURLConnection.HTTP_BAD_METHOD; import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; import static java.nio.charset.StandardCharsets.UTF_8; @@ -13,10 +16,15 @@ import com.retailsvc.http.internal.ProblemDetail; import com.retailsvc.http.internal.ProblemDetailRenderer; import com.retailsvc.http.internal.ResourceSource; +import com.retailsvc.http.spec.HttpMethod; import java.io.InputStream; import java.nio.file.Path; +import java.time.Duration; import java.util.List; +import java.util.Locale; import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -54,6 +62,126 @@ public static ResponseDecorator securityHeadersDecorator() { }; } + /** + * Returns a {@link RequestHandler} that answers CORS preflight {@code OPTIONS} requests for any + * path the caller wires it under (typically via {@code + * OpenApiServer.builder().extraRoute("/api/*", Handlers.corsPreflightHandler(...))}). + * + *

Requests are validated in order: origin against {@code allowedOrigins} (exact match), {@code + * Access-Control-Request-Method} against {@code allowedMethods}, and each header in {@code + * Access-Control-Request-Headers} against {@code allowedHeaders} (case-insensitive). A non-{@code + * OPTIONS} request yields {@code 405} with {@code Allow: OPTIONS}; a missing {@code Origin} or + * {@code Access-Control-Request-Method} header yields {@code 400}; any disallowed origin / method + * / header yields {@code 403} with no CORS headers (the browser then blocks the request). + * + *

On success the response is {@code 204 No Content} with {@code Access-Control-Allow-Origin} + * echoing the request's {@code Origin}, the configured method and header allowlists, and {@code + * Vary: Origin} so caches segment by origin. {@code Access-Control-Allow-Credentials} and {@code + * Access-Control-Max-Age} are emitted only when enabled. + * + * @param allowedOrigins exact-match origin allowlist; never {@code null} + * @param allowedMethods non-empty list of methods to advertise in {@code Allow-Methods} + * @param allowedHeaders header allowlist (matched case-insensitively); may be empty (then {@code + * Access-Control-Allow-Headers} is omitted) + * @param allowCredentials whether to emit {@code Access-Control-Allow-Credentials: true} + * @param maxAge {@code Access-Control-Max-Age} value; {@code null} omits the header + */ + public static RequestHandler corsPreflightHandler( + List allowedOrigins, + List allowedMethods, + List allowedHeaders, + boolean allowCredentials, + Duration maxAge) { + Objects.requireNonNull(allowedOrigins, "allowedOrigins must not be null"); + Set origins = Set.copyOf(allowedOrigins); + return corsPreflightHandler( + origins::contains, allowedMethods, allowedHeaders, allowCredentials, maxAge); + } + + /** + * Predicate-based overload of {@link #corsPreflightHandler(List, List, List, boolean, Duration)} + * for callers that need dynamic origin policy (regex, suffix match, config lookup). + */ + public static RequestHandler corsPreflightHandler( + Predicate originAllowed, + List allowedMethods, + List allowedHeaders, + boolean allowCredentials, + Duration maxAge) { + Objects.requireNonNull(originAllowed, "originAllowed must not be null"); + Objects.requireNonNull(allowedMethods, "allowedMethods must not be null"); + Objects.requireNonNull(allowedHeaders, "allowedHeaders must not be null"); + if (allowedMethods.isEmpty()) { + throw new IllegalArgumentException("allowedMethods must not be empty"); + } + if (maxAge != null && (maxAge.isNegative() || maxAge.getSeconds() > Integer.MAX_VALUE)) { + throw new IllegalArgumentException( + "maxAge must be non-negative and fit in an int number of seconds, got " + maxAge); + } + + String allowMethodsHeader = + allowedMethods.stream().map(Enum::name).collect(Collectors.joining(", ")); + String allowHeadersHeader = String.join(", ", allowedHeaders); + Set headerAllowlistLower = + allowedHeaders.stream() + .map(h -> h.toLowerCase(Locale.ROOT)) + .collect(Collectors.toUnmodifiableSet()); + String maxAgeHeader = maxAge == null ? null : Long.toString(maxAge.getSeconds()); + + return req -> { + if (req.method() != OPTIONS) { + return Response.status(HTTP_BAD_METHOD).withHeader("Allow", "OPTIONS"); + } + String origin = req.header("Origin").orElse(null); + if (origin == null) { + throw new BadRequestException("CORS preflight is missing the Origin header"); + } + String requestMethod = req.header("Access-Control-Request-Method").orElse(null); + if (requestMethod == null) { + throw new BadRequestException( + "CORS preflight is missing the Access-Control-Request-Method header"); + } + if (!originAllowed.test(origin)) { + return Response.status(HTTP_FORBIDDEN); + } + HttpMethod parsedMethod; + try { + parsedMethod = HttpMethod.parse(requestMethod); + } catch (IllegalArgumentException e) { + return Response.status(HTTP_FORBIDDEN); + } + if (!allowedMethods.contains(parsedMethod)) { + return Response.status(HTTP_FORBIDDEN); + } + String requestedHeaders = req.header("Access-Control-Request-Headers").orElse(""); + for (String raw : requestedHeaders.split(",")) { + String h = raw.trim().toLowerCase(Locale.ROOT); + if (h.isEmpty()) { + continue; + } + if (!headerAllowlistLower.contains(h)) { + return Response.status(HTTP_FORBIDDEN); + } + } + + Response resp = + Response.status(HTTP_NO_CONTENT) + .withHeader("Access-Control-Allow-Origin", origin) + .withHeader("Access-Control-Allow-Methods", allowMethodsHeader) + .withHeader("Vary", "Origin"); + if (!allowedHeaders.isEmpty()) { + resp = resp.withHeader("Access-Control-Allow-Headers", allowHeadersHeader); + } + if (allowCredentials) { + resp = resp.withHeader("Access-Control-Allow-Credentials", "true"); + } + if (maxAgeHeader != null) { + resp = resp.withHeader("Access-Control-Max-Age", maxAgeHeader); + } + return resp; + }; + } + public static ExceptionHandler defaultExceptionHandler() { return t -> switch (t) { diff --git a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java new file mode 100644 index 0000000..9229a4a --- /dev/null +++ b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java @@ -0,0 +1,58 @@ +package com.retailsvc.http; + +import static com.retailsvc.http.spec.HttpMethod.DELETE; +import static com.retailsvc.http.spec.HttpMethod.GET; +import static com.retailsvc.http.spec.HttpMethod.OPTIONS; +import static com.retailsvc.http.spec.HttpMethod.POST; +import static com.retailsvc.http.spec.HttpMethod.PUT; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.spec.HttpMethod; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +class CorsPreflightHandlerTest { + + private static final List METHODS = List.of(GET, POST, PUT, DELETE); + private static final List HEADERS = List.of("content-type", "authorization"); + private static final List ORIGINS = List.of("https://app.example.com"); + + private static Request preflight(String origin, String requestMethod, String requestHeaders) { + UnaryOperator lookup = + name -> + switch (name.toLowerCase(java.util.Locale.ROOT)) { + case "origin" -> origin; + case "access-control-request-method" -> requestMethod; + case "access-control-request-headers" -> requestHeaders; + default -> null; + }; + return new Request(new byte[0], null, null, null, Map.of(), null, lookup, Map.of(), OPTIONS); + } + + private static Request bare(HttpMethod method) { + return new Request(new byte[0], null, null, null, Map.of(), null, n -> null, Map.of(), method); + } + + @Test + void corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, true, Duration.ofMinutes(10)); + + Response resp = + handler.handle(preflight("https://app.example.com", "POST", "content-type, authorization")); + + assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); + assertThat(resp.body()).isNull(); + assertThat(resp.headers()) + .containsEntry("Access-Control-Allow-Origin", "https://app.example.com") + .containsEntry("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") + .containsEntry("Access-Control-Allow-Headers", "content-type, authorization") + .containsEntry("Access-Control-Allow-Credentials", "true") + .containsEntry("Access-Control-Max-Age", "600") + .containsEntry("Vary", "Origin"); + } +} From e43c47a662da1b34e5e476c94cefcc49b2d85090 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:22:29 +0200 Subject: [PATCH 04/12] test: Import Locale instead of inlining FQN in CORS preflight test --- src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java index 9229a4a..8a78f48 100644 --- a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +++ b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java @@ -11,6 +11,7 @@ import com.retailsvc.http.spec.HttpMethod; import java.time.Duration; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.UnaryOperator; import org.junit.jupiter.api.Test; @@ -24,7 +25,7 @@ class CorsPreflightHandlerTest { private static Request preflight(String origin, String requestMethod, String requestHeaders) { UnaryOperator lookup = name -> - switch (name.toLowerCase(java.util.Locale.ROOT)) { + switch (name.toLowerCase(Locale.ROOT)) { case "origin" -> origin; case "access-control-request-method" -> requestMethod; case "access-control-request-headers" -> requestHeaders; From af63f22cfb575eb390c27d337e45d402ddfd40db Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:26:03 +0200 Subject: [PATCH 05/12] test: Cover CORS preflight optional header omission --- .../http/CorsPreflightHandlerTest.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java index 8a78f48..e9f118d 100644 --- a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +++ b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java @@ -56,4 +56,44 @@ void corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight() { .containsEntry("Access-Control-Max-Age", "600") .containsEntry("Vary", "Origin"); } + + @Test + void corsPreflightHandlerOmitsAllowCredentialsWhenFalse() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, Duration.ofMinutes(10)); + + Response resp = handler.handle(preflight("https://app.example.com", "POST", "content-type")); + + assertThat(resp.headers()).doesNotContainKey("Access-Control-Allow-Credentials"); + } + + @Test + void corsPreflightHandlerOmitsMaxAgeWhenNull() { + RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, true, null); + + Response resp = handler.handle(preflight("https://app.example.com", "POST", "content-type")); + + assertThat(resp.headers()).doesNotContainKey("Access-Control-Max-Age"); + } + + @Test + void corsPreflightHandlerEmitsMaxAgeInSecondsWhenSet() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, Duration.ofSeconds(75)); + + Response resp = handler.handle(preflight("https://app.example.com", "POST", "content-type")); + + assertThat(resp.headers()).containsEntry("Access-Control-Max-Age", "75"); + } + + @Test + void corsPreflightHandlerOmitsAllowHeadersWhenListEmpty() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, List.of(), false, null); + + Response resp = handler.handle(preflight("https://app.example.com", "POST", "")); + + assertThat(resp.headers()).doesNotContainKey("Access-Control-Allow-Headers"); + assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); + } } From 58ac1de157bc127a4b84b48700a1e75e4b32c9a1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:26:45 +0200 Subject: [PATCH 06/12] test: Cover CORS preflight rejection paths --- .../http/CorsPreflightHandlerTest.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java index e9f118d..592f8ff 100644 --- a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +++ b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java @@ -5,8 +5,11 @@ import static com.retailsvc.http.spec.HttpMethod.OPTIONS; import static com.retailsvc.http.spec.HttpMethod.POST; import static com.retailsvc.http.spec.HttpMethod.PUT; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_NO_CONTENT; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.retailsvc.http.spec.HttpMethod; import java.time.Duration; @@ -96,4 +99,72 @@ void corsPreflightHandlerOmitsAllowHeadersWhenListEmpty() { assertThat(resp.headers()).doesNotContainKey("Access-Control-Allow-Headers"); assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); } + + @Test + void corsPreflightHandlerRejectsNonOptionsWith405AndAllowOptions() { + RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + Response resp = handler.handle(bare(GET)); + + assertThat(resp.status()).isEqualTo(HTTP_BAD_METHOD); + assertThat(resp.headers()).containsEntry("Allow", "OPTIONS"); + } + + @Test + void corsPreflightHandlerRejectsMissingOriginWith400() { + RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + assertThatThrownBy(() -> handler.handle(preflight(null, "POST", "content-type"))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Origin"); + } + + @Test + void corsPreflightHandlerRejectsMissingRequestMethodWith400() { + RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + assertThatThrownBy( + () -> handler.handle(preflight("https://app.example.com", null, "content-type"))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Access-Control-Request-Method"); + } + + @Test + void corsPreflightHandlerRejectsDisallowedOriginWith403() { + RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + Response resp = handler.handle(preflight("https://evil.example.com", "POST", "content-type")); + + assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); + assertThat(resp.headers()).doesNotContainKey("Access-Control-Allow-Origin"); + } + + @Test + void corsPreflightHandlerRejectsDisallowedMethodWith403() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, List.of(GET), HEADERS, false, null); + + Response resp = handler.handle(preflight("https://app.example.com", "DELETE", "content-type")); + + assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); + } + + @Test + void corsPreflightHandlerRejectsDisallowedHeaderWith403() { + RequestHandler handler = + Handlers.corsPreflightHandler(ORIGINS, METHODS, List.of("content-type"), false, null); + + Response resp = handler.handle(preflight("https://app.example.com", "POST", "x-secret")); + + assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); + } + + @Test + void corsPreflightHandlerRejectsUnknownMethodTokenWith403() { + RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + + Response resp = handler.handle(preflight("https://app.example.com", "BOGUS", "content-type")); + + assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); + } } From b9954e60f8f1b45c1547cd04a918d047aaba8c5f Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:27:17 +0200 Subject: [PATCH 07/12] test: Cover CORS preflight case-insensitivity, Vary, list overload --- .../http/CorsPreflightHandlerTest.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java index 592f8ff..baac7e6 100644 --- a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +++ b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.function.Predicate; import java.util.function.UnaryOperator; import org.junit.jupiter.api.Test; @@ -167,4 +168,48 @@ void corsPreflightHandlerRejectsUnknownMethodTokenWith403() { assertThat(resp.status()).isEqualTo(HTTP_FORBIDDEN); } + + @Test + void corsPreflightHandlerMatchesHeadersCaseInsensitively() { + RequestHandler handler = + Handlers.corsPreflightHandler( + ORIGINS, METHODS, List.of("Content-Type", "Authorization"), false, null); + + Response resp = + handler.handle(preflight("https://app.example.com", "POST", "CONTENT-TYPE, authorization")); + + assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); + } + + @Test + void corsPreflightHandlerEchoesOriginAndIncludesVary() { + Predicate anyExampleOrigin = o -> o.endsWith(".example.com"); + RequestHandler handler = + Handlers.corsPreflightHandler(anyExampleOrigin, METHODS, HEADERS, false, null); + + Response resp = + handler.handle(preflight("https://tenant-7.example.com", "POST", "content-type")); + + assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT); + assertThat(resp.headers()) + .containsEntry("Access-Control-Allow-Origin", "https://tenant-7.example.com") + .containsEntry("Vary", "Origin"); + } + + @Test + void corsPreflightHandlerListOverloadDelegatesToPredicateBehaviour() { + RequestHandler list = + Handlers.corsPreflightHandler( + List.of("https://a.example.com", "https://b.example.com"), + METHODS, + HEADERS, + false, + null); + + Response allowed = list.handle(preflight("https://b.example.com", "POST", "content-type")); + Response denied = list.handle(preflight("https://c.example.com", "POST", "content-type")); + + assertThat(allowed.status()).isEqualTo(HTTP_NO_CONTENT); + assertThat(denied.status()).isEqualTo(HTTP_FORBIDDEN); + } } From 9642bdaf268510e3a67adb3d6179a3ab435f91a4 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:27:55 +0200 Subject: [PATCH 08/12] test: Cover CORS preflight constructor validation --- .../http/CorsPreflightHandlerTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java index baac7e6..f6e435b 100644 --- a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +++ b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java @@ -212,4 +212,63 @@ void corsPreflightHandlerListOverloadDelegatesToPredicateBehaviour() { assertThat(allowed.status()).isEqualTo(HTTP_NO_CONTENT); assertThat(denied.status()).isEqualTo(HTTP_FORBIDDEN); } + + @Test + void corsPreflightHandlerRejectsNullOriginList() { + assertThatThrownBy( + () -> Handlers.corsPreflightHandler((List) null, METHODS, HEADERS, false, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("allowedOrigins"); + } + + @Test + void corsPreflightHandlerRejectsNullOriginPredicate() { + assertThatThrownBy( + () -> + Handlers.corsPreflightHandler( + (Predicate) null, METHODS, HEADERS, false, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("originAllowed"); + } + + @Test + void corsPreflightHandlerRejectsNullMethods() { + assertThatThrownBy(() -> Handlers.corsPreflightHandler(ORIGINS, null, HEADERS, false, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("allowedMethods"); + } + + @Test + void corsPreflightHandlerRejectsEmptyMethods() { + assertThatThrownBy( + () -> Handlers.corsPreflightHandler(ORIGINS, List.of(), HEADERS, false, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allowedMethods"); + } + + @Test + void corsPreflightHandlerRejectsNullHeaders() { + assertThatThrownBy(() -> Handlers.corsPreflightHandler(ORIGINS, METHODS, null, false, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("allowedHeaders"); + } + + @Test + void corsPreflightHandlerRejectsNegativeMaxAge() { + assertThatThrownBy( + () -> + Handlers.corsPreflightHandler( + ORIGINS, METHODS, HEADERS, false, Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxAge"); + } + + @Test + void corsPreflightHandlerRejectsOverflowingMaxAge() { + Duration tooBig = Duration.ofSeconds((long) Integer.MAX_VALUE + 1); + assertThatThrownBy( + () -> Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, tooBig)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxAge"); + } } From c958b3bc84bf46605881e45f923ad1c8b9215c96 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:29:12 +0200 Subject: [PATCH 09/12] docs: Document CORS preflight handler in README --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 790df12..c07d853 100644 --- a/README.md +++ b/README.md @@ -947,6 +947,8 @@ Built-in helpers: classpath resource or filesystem file (content-type inferred from extension; the stream is opened and closed per request, and the handler owns its lifecycle). Throws `IllegalArgumentException` at construction if the resource or file is missing. +- `Handlers.corsPreflightHandler(...)` — answers CORS `OPTIONS` preflight requests against + caller-supplied allowlists. See [CORS preflight](#cors-preflight) below. ### Wildcards in extra routes @@ -1015,6 +1017,31 @@ empty dependency list; the exception never reaches the configured `ExceptionHand `HEAD` are accepted; other methods return `405 Method Not Allowed` with an `Allow: GET, HEAD` header. +### CORS preflight + +`Handlers.corsPreflightHandler(...)` answers `OPTIONS` preflight requests so browsers can +perform cross-origin calls against the server. The handler is preflight-only — wire it on a +wildcard `extraRoute` covering the routes you want to expose to browsers. + +``` java +var server = OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .extraRoute("/api/**", Handlers.corsPreflightHandler( + List.of("https://app.example.com"), + List.of(GET, POST, PUT, DELETE), + List.of("content-type", "authorization"), + true, // Access-Control-Allow-Credentials + Duration.ofMinutes(10))) // Access-Control-Max-Age + .build(); +``` + +For dynamic origin policy (regex match, suffix match, tenant lookup) pass a `Predicate` +instead of a `List`. Allowed-headers comparison is case-insensitive. Disallowed origins, +methods, or headers return `403` with no CORS headers (the browser then blocks the request); +non-`OPTIONS` requests return `405` with `Allow: OPTIONS`; preflights missing the `Origin` or +`Access-Control-Request-Method` header return `400`. + ## End-to-end example Gson on the classpath for request/response JSON, SnakeYAML on the classpath for the spec, one From 7dc8f9fe35b1361c85aea488740c2fdc3fdbf1e2 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 12:40:38 +0200 Subject: [PATCH 10/12] refactor: Address Sonar findings in CORS preflight handler Reduce cognitive complexity of the predicate corsPreflightHandler overload by extracting requireHeader / isPreflightAllowed / parseMethodOrNull / requestedHeadersAllowed / buildPreflightSuccess helpers. Introduce an ALLOW constant for the repeated header name. Mark the swallowed parse exception with an unnamed pattern. Hoist throwing argument expressions out of assertThatThrownBy lambdas so each lambda has a single risky call. --- .../java/com/retailsvc/http/Handlers.java | 134 +++++++++++------- .../http/CorsPreflightHandlerTest.java | 18 +-- 2 files changed, 96 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index b6afffe..8bd178a 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -33,6 +33,7 @@ public final class Handlers { private static final Logger LOG = LoggerFactory.getLogger(Handlers.class); + private static final String ALLOW = "Allow"; private Handlers() {} @@ -127,59 +128,96 @@ public static RequestHandler corsPreflightHandler( .map(h -> h.toLowerCase(Locale.ROOT)) .collect(Collectors.toUnmodifiableSet()); String maxAgeHeader = maxAge == null ? null : Long.toString(maxAge.getSeconds()); + boolean emitAllowHeaders = !allowedHeaders.isEmpty(); return req -> { if (req.method() != OPTIONS) { - return Response.status(HTTP_BAD_METHOD).withHeader("Allow", "OPTIONS"); + return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "OPTIONS"); } - String origin = req.header("Origin").orElse(null); - if (origin == null) { - throw new BadRequestException("CORS preflight is missing the Origin header"); - } - String requestMethod = req.header("Access-Control-Request-Method").orElse(null); - if (requestMethod == null) { - throw new BadRequestException( - "CORS preflight is missing the Access-Control-Request-Method header"); - } - if (!originAllowed.test(origin)) { - return Response.status(HTTP_FORBIDDEN); - } - HttpMethod parsedMethod; - try { - parsedMethod = HttpMethod.parse(requestMethod); - } catch (IllegalArgumentException e) { - return Response.status(HTTP_FORBIDDEN); - } - if (!allowedMethods.contains(parsedMethod)) { + String origin = requireHeader(req, "Origin"); + String requestMethod = requireHeader(req, "Access-Control-Request-Method"); + if (!isPreflightAllowed( + req, origin, requestMethod, originAllowed, allowedMethods, headerAllowlistLower)) { return Response.status(HTTP_FORBIDDEN); } - String requestedHeaders = req.header("Access-Control-Request-Headers").orElse(""); - for (String raw : requestedHeaders.split(",")) { - String h = raw.trim().toLowerCase(Locale.ROOT); - if (h.isEmpty()) { - continue; - } - if (!headerAllowlistLower.contains(h)) { - return Response.status(HTTP_FORBIDDEN); - } - } + return buildPreflightSuccess( + origin, + allowMethodsHeader, + allowHeadersHeader, + emitAllowHeaders, + allowCredentials, + maxAgeHeader); + }; + } - Response resp = - Response.status(HTTP_NO_CONTENT) - .withHeader("Access-Control-Allow-Origin", origin) - .withHeader("Access-Control-Allow-Methods", allowMethodsHeader) - .withHeader("Vary", "Origin"); - if (!allowedHeaders.isEmpty()) { - resp = resp.withHeader("Access-Control-Allow-Headers", allowHeadersHeader); - } - if (allowCredentials) { - resp = resp.withHeader("Access-Control-Allow-Credentials", "true"); + private static String requireHeader(Request req, String name) { + return req.header(name) + .orElseThrow( + () -> new BadRequestException("CORS preflight is missing the " + name + " header")); + } + + private static boolean isPreflightAllowed( + Request req, + String origin, + String requestMethod, + Predicate originAllowed, + List allowedMethods, + Set headerAllowlistLower) { + if (!originAllowed.test(origin)) { + return false; + } + HttpMethod parsed = parseMethodOrNull(requestMethod); + if (parsed == null || !allowedMethods.contains(parsed)) { + return false; + } + return requestedHeadersAllowed(req, headerAllowlistLower); + } + + private static HttpMethod parseMethodOrNull(String s) { + try { + return HttpMethod.parse(s); + } catch (IllegalArgumentException _) { + // Unknown method token — treated as disallowed by the caller. + return null; + } + } + + private static boolean requestedHeadersAllowed(Request req, Set allowedLower) { + String requested = req.header("Access-Control-Request-Headers").orElse(""); + for (String raw : requested.split(",")) { + String h = raw.trim().toLowerCase(Locale.ROOT); + if (h.isEmpty()) { + continue; } - if (maxAgeHeader != null) { - resp = resp.withHeader("Access-Control-Max-Age", maxAgeHeader); + if (!allowedLower.contains(h)) { + return false; } - return resp; - }; + } + return true; + } + + private static Response buildPreflightSuccess( + String origin, + String allowMethodsHeader, + String allowHeadersHeader, + boolean emitAllowHeaders, + boolean allowCredentials, + String maxAgeHeader) { + Response resp = + Response.status(HTTP_NO_CONTENT) + .withHeader("Access-Control-Allow-Origin", origin) + .withHeader("Access-Control-Allow-Methods", allowMethodsHeader) + .withHeader("Vary", "Origin"); + if (emitAllowHeaders) { + resp = resp.withHeader("Access-Control-Allow-Headers", allowHeadersHeader); + } + if (allowCredentials) { + resp = resp.withHeader("Access-Control-Allow-Credentials", "true"); + } + if (maxAgeHeader != null) { + resp = resp.withHeader("Access-Control-Max-Age", maxAgeHeader); + } + return resp; } public static ExceptionHandler defaultExceptionHandler() { @@ -199,7 +237,7 @@ public static ExceptionHandler defaultExceptionHandler() { case MethodNotAllowedException mna -> Response.status(HTTP_BAD_METHOD) .withHeader( - "Allow", + ALLOW, mna.allowed().stream().map(Enum::name).collect(Collectors.joining(", "))); default -> { LOG.error("Unhandled exception in handler", t); @@ -213,7 +251,7 @@ public static RequestHandler aliveHandler() { return req -> switch (req.method()) { case GET, HEAD -> Response.empty(); - default -> Response.status(HTTP_BAD_METHOD).withHeader("Allow", "GET, HEAD"); + default -> Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "GET, HEAD"); }; } @@ -238,7 +276,7 @@ public static RequestHandler healthHandler(Supplier probe) { Objects.requireNonNull(probe, "probe"); return req -> { if (req.method() != GET && req.method() != HEAD) { - return Response.status(HTTP_BAD_METHOD).withHeader("Allow", "GET, HEAD"); + return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "GET, HEAD"); } boolean up; List dependencies; @@ -297,7 +335,7 @@ private static RequestHandler resourceHandler(ResourceSource source) { Response.status(HTTP_OK) .withContentType(contentType) .withHeader("Content-Length", String.valueOf(length)); - default -> Response.status(HTTP_BAD_METHOD).withHeader("Allow", "GET, HEAD"); + default -> Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "GET, HEAD"); }; } } diff --git a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java index f6e435b..8efa154 100644 --- a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +++ b/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java @@ -114,8 +114,9 @@ void corsPreflightHandlerRejectsNonOptionsWith405AndAllowOptions() { @Test void corsPreflightHandlerRejectsMissingOriginWith400() { RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + Request noOrigin = preflight(null, "POST", "content-type"); - assertThatThrownBy(() -> handler.handle(preflight(null, "POST", "content-type"))) + assertThatThrownBy(() -> handler.handle(noOrigin)) .isInstanceOf(BadRequestException.class) .hasMessageContaining("Origin"); } @@ -123,9 +124,9 @@ void corsPreflightHandlerRejectsMissingOriginWith400() { @Test void corsPreflightHandlerRejectsMissingRequestMethodWith400() { RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + Request noRequestMethod = preflight("https://app.example.com", null, "content-type"); - assertThatThrownBy( - () -> handler.handle(preflight("https://app.example.com", null, "content-type"))) + assertThatThrownBy(() -> handler.handle(noRequestMethod)) .isInstanceOf(BadRequestException.class) .hasMessageContaining("Access-Control-Request-Method"); } @@ -240,8 +241,9 @@ void corsPreflightHandlerRejectsNullMethods() { @Test void corsPreflightHandlerRejectsEmptyMethods() { - assertThatThrownBy( - () -> Handlers.corsPreflightHandler(ORIGINS, List.of(), HEADERS, false, null)) + List empty = List.of(); + + assertThatThrownBy(() -> Handlers.corsPreflightHandler(ORIGINS, empty, HEADERS, false, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("allowedMethods"); } @@ -255,10 +257,10 @@ void corsPreflightHandlerRejectsNullHeaders() { @Test void corsPreflightHandlerRejectsNegativeMaxAge() { + Duration negative = Duration.ofSeconds(-1); + assertThatThrownBy( - () -> - Handlers.corsPreflightHandler( - ORIGINS, METHODS, HEADERS, false, Duration.ofSeconds(-1))) + () -> Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, negative)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("maxAge"); } From e74b9e84c7132ed29529ff859e5d23cb9330c316 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 13:25:25 +0200 Subject: [PATCH 11/12] refactor: Extract GET_HEAD constant for duplicated Allow header value --- src/main/java/com/retailsvc/http/Handlers.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 8bd178a..bf989b0 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -34,6 +34,7 @@ public final class Handlers { private static final Logger LOG = LoggerFactory.getLogger(Handlers.class); private static final String ALLOW = "Allow"; + private static final String GET_HEAD = "GET, HEAD"; private Handlers() {} @@ -251,7 +252,7 @@ public static RequestHandler aliveHandler() { return req -> switch (req.method()) { case GET, HEAD -> Response.empty(); - default -> Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "GET, HEAD"); + default -> Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, GET_HEAD); }; } @@ -276,7 +277,7 @@ public static RequestHandler healthHandler(Supplier probe) { Objects.requireNonNull(probe, "probe"); return req -> { if (req.method() != GET && req.method() != HEAD) { - return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "GET, HEAD"); + return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, GET_HEAD); } boolean up; List dependencies; @@ -335,7 +336,7 @@ private static RequestHandler resourceHandler(ResourceSource source) { Response.status(HTTP_OK) .withContentType(contentType) .withHeader("Content-Length", String.valueOf(length)); - default -> Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "GET, HEAD"); + default -> Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, GET_HEAD); }; } } From d561aac58b09f7ed9db1f7790371713636a7527c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 25 May 2026 14:05:30 +0200 Subject: [PATCH 12/12] 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. --- README.md | 6 +- src/main/java/com/retailsvc/http/Cors.java | 184 ++++++++++++++++++ .../java/com/retailsvc/http/Handlers.java | 165 ---------------- ...reflightHandlerTest.java => CorsTest.java} | 97 +++++---- 4 files changed, 232 insertions(+), 220 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/Cors.java rename src/test/java/com/retailsvc/http/{CorsPreflightHandlerTest.java => CorsTest.java} (66%) diff --git a/README.md b/README.md index c07d853..3c64dc3 100644 --- a/README.md +++ b/README.md @@ -947,7 +947,7 @@ Built-in helpers: classpath resource or filesystem file (content-type inferred from extension; the stream is opened and closed per request, and the handler owns its lifecycle). Throws `IllegalArgumentException` at construction if the resource or file is missing. -- `Handlers.corsPreflightHandler(...)` — answers CORS `OPTIONS` preflight requests against +- `Cors.preflightHandler(...)` — answers CORS `OPTIONS` preflight requests against caller-supplied allowlists. See [CORS preflight](#cors-preflight) below. ### Wildcards in extra routes @@ -1019,7 +1019,7 @@ header. ### CORS preflight -`Handlers.corsPreflightHandler(...)` answers `OPTIONS` preflight requests so browsers can +`Cors.preflightHandler(...)` answers `OPTIONS` preflight requests so browsers can perform cross-origin calls against the server. The handler is preflight-only — wire it on a wildcard `extraRoute` covering the routes you want to expose to browsers. @@ -1027,7 +1027,7 @@ wildcard `extraRoute` covering the routes you want to expose to browsers. var server = OpenApiServer.builder() .spec(spec) .handlers(handlers) - .extraRoute("/api/**", Handlers.corsPreflightHandler( + .extraRoute("/api/**", Cors.preflightHandler( List.of("https://app.example.com"), List.of(GET, POST, PUT, DELETE), List.of("content-type", "authorization"), diff --git a/src/main/java/com/retailsvc/http/Cors.java b/src/main/java/com/retailsvc/http/Cors.java new file mode 100644 index 0000000..4df3eac --- /dev/null +++ b/src/main/java/com/retailsvc/http/Cors.java @@ -0,0 +1,184 @@ +package com.retailsvc.http; + +import static com.retailsvc.http.spec.HttpMethod.OPTIONS; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; + +import com.retailsvc.http.spec.HttpMethod; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * CORS support. Currently exposes {@link #preflightHandler(List, List, List, boolean, Duration) a + * preflight handler} that answers browser {@code OPTIONS} preflight requests; future cross-origin + * helpers (e.g. a response decorator for {@code Access-Control-Expose-Headers}) will live here too. + */ +public final class Cors { + + private static final String ALLOW = "Allow"; + + private Cors() {} + + /** + * Returns a {@link RequestHandler} that answers CORS preflight {@code OPTIONS} requests for any + * path the caller wires it under (typically via {@code + * OpenApiServer.builder().extraRoute("/api/*", Cors.preflightHandler(...))}). + * + *

Requests are validated in order: origin against {@code allowedOrigins} (exact match), {@code + * Access-Control-Request-Method} against {@code allowedMethods}, and each header in {@code + * Access-Control-Request-Headers} against {@code allowedHeaders} (case-insensitive). A non-{@code + * OPTIONS} request yields {@code 405} with {@code Allow: OPTIONS}; a missing {@code Origin} or + * {@code Access-Control-Request-Method} header yields {@code 400}; any disallowed origin / method + * / header yields {@code 403} with no CORS headers (the browser then blocks the request). + * + *

On success the response is {@code 204 No Content} with {@code Access-Control-Allow-Origin} + * echoing the request's {@code Origin}, the configured method and header allowlists, and {@code + * Vary: Origin} so caches segment by origin. {@code Access-Control-Allow-Credentials} and {@code + * Access-Control-Max-Age} are emitted only when enabled. + * + * @param allowedOrigins exact-match origin allowlist; never {@code null} + * @param allowedMethods non-empty list of methods to advertise in {@code Allow-Methods} + * @param allowedHeaders header allowlist (matched case-insensitively); may be empty (then {@code + * Access-Control-Allow-Headers} is omitted) + * @param allowCredentials whether to emit {@code Access-Control-Allow-Credentials: true} + * @param maxAge {@code Access-Control-Max-Age} value; {@code null} omits the header + */ + public static RequestHandler preflightHandler( + List allowedOrigins, + List allowedMethods, + List allowedHeaders, + boolean allowCredentials, + Duration maxAge) { + Objects.requireNonNull(allowedOrigins, "allowedOrigins must not be null"); + Set origins = Set.copyOf(allowedOrigins); + return preflightHandler( + origins::contains, allowedMethods, allowedHeaders, allowCredentials, maxAge); + } + + /** + * Predicate-based overload of {@link #preflightHandler(List, List, List, boolean, Duration)} for + * callers that need dynamic origin policy (regex, suffix match, config lookup). + */ + public static RequestHandler preflightHandler( + Predicate originAllowed, + List allowedMethods, + List allowedHeaders, + boolean allowCredentials, + Duration maxAge) { + Objects.requireNonNull(originAllowed, "originAllowed must not be null"); + Objects.requireNonNull(allowedMethods, "allowedMethods must not be null"); + Objects.requireNonNull(allowedHeaders, "allowedHeaders must not be null"); + if (allowedMethods.isEmpty()) { + throw new IllegalArgumentException("allowedMethods must not be empty"); + } + if (maxAge != null && (maxAge.isNegative() || maxAge.getSeconds() > Integer.MAX_VALUE)) { + throw new IllegalArgumentException( + "maxAge must be non-negative and fit in an int number of seconds, got " + maxAge); + } + + String allowMethodsHeader = + allowedMethods.stream().map(Enum::name).collect(Collectors.joining(", ")); + String allowHeadersHeader = String.join(", ", allowedHeaders); + Set headerAllowlistLower = + allowedHeaders.stream() + .map(h -> h.toLowerCase(Locale.ROOT)) + .collect(Collectors.toUnmodifiableSet()); + String maxAgeHeader = maxAge == null ? null : Long.toString(maxAge.getSeconds()); + boolean emitAllowHeaders = !allowedHeaders.isEmpty(); + + return req -> { + if (req.method() != OPTIONS) { + return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "OPTIONS"); + } + String origin = requireHeader(req, "Origin"); + String requestMethod = requireHeader(req, "Access-Control-Request-Method"); + if (!isPreflightAllowed( + req, origin, requestMethod, originAllowed, allowedMethods, headerAllowlistLower)) { + return Response.status(HTTP_FORBIDDEN); + } + return buildPreflightSuccess( + origin, + allowMethodsHeader, + allowHeadersHeader, + emitAllowHeaders, + allowCredentials, + maxAgeHeader); + }; + } + + private static String requireHeader(Request req, String name) { + return req.header(name) + .orElseThrow( + () -> new BadRequestException("CORS preflight is missing the " + name + " header")); + } + + private static boolean isPreflightAllowed( + Request req, + String origin, + String requestMethod, + Predicate originAllowed, + List allowedMethods, + Set headerAllowlistLower) { + if (!originAllowed.test(origin)) { + return false; + } + HttpMethod parsed = parseMethodOrNull(requestMethod); + if (parsed == null || !allowedMethods.contains(parsed)) { + return false; + } + return requestedHeadersAllowed(req, headerAllowlistLower); + } + + private static HttpMethod parseMethodOrNull(String s) { + try { + return HttpMethod.parse(s); + } catch (IllegalArgumentException _) { + // Unknown method token — treated as disallowed by the caller. + return null; + } + } + + private static boolean requestedHeadersAllowed(Request req, Set allowedLower) { + String requested = req.header("Access-Control-Request-Headers").orElse(""); + for (String raw : requested.split(",")) { + String h = raw.trim().toLowerCase(Locale.ROOT); + if (h.isEmpty()) { + continue; + } + if (!allowedLower.contains(h)) { + return false; + } + } + return true; + } + + private static Response buildPreflightSuccess( + String origin, + String allowMethodsHeader, + String allowHeadersHeader, + boolean emitAllowHeaders, + boolean allowCredentials, + String maxAgeHeader) { + Response resp = + Response.status(HTTP_NO_CONTENT) + .withHeader("Access-Control-Allow-Origin", origin) + .withHeader("Access-Control-Allow-Methods", allowMethodsHeader) + .withHeader("Vary", "Origin"); + if (emitAllowHeaders) { + resp = resp.withHeader("Access-Control-Allow-Headers", allowHeadersHeader); + } + if (allowCredentials) { + resp = resp.withHeader("Access-Control-Allow-Credentials", "true"); + } + if (maxAgeHeader != null) { + resp = resp.withHeader("Access-Control-Max-Age", maxAgeHeader); + } + return resp; + } +} diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index bf989b0..59a3c7d 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -2,12 +2,9 @@ import static com.retailsvc.http.spec.HttpMethod.GET; import static com.retailsvc.http.spec.HttpMethod.HEAD; -import static com.retailsvc.http.spec.HttpMethod.OPTIONS; import static java.net.HttpURLConnection.HTTP_BAD_METHOD; import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; -import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; -import static java.net.HttpURLConnection.HTTP_NO_CONTENT; import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; import static java.nio.charset.StandardCharsets.UTF_8; @@ -16,15 +13,10 @@ import com.retailsvc.http.internal.ProblemDetail; import com.retailsvc.http.internal.ProblemDetailRenderer; import com.retailsvc.http.internal.ResourceSource; -import com.retailsvc.http.spec.HttpMethod; import java.io.InputStream; import java.nio.file.Path; -import java.time.Duration; import java.util.List; -import java.util.Locale; import java.util.Objects; -import java.util.Set; -import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -64,163 +56,6 @@ public static ResponseDecorator securityHeadersDecorator() { }; } - /** - * Returns a {@link RequestHandler} that answers CORS preflight {@code OPTIONS} requests for any - * path the caller wires it under (typically via {@code - * OpenApiServer.builder().extraRoute("/api/*", Handlers.corsPreflightHandler(...))}). - * - *

Requests are validated in order: origin against {@code allowedOrigins} (exact match), {@code - * Access-Control-Request-Method} against {@code allowedMethods}, and each header in {@code - * Access-Control-Request-Headers} against {@code allowedHeaders} (case-insensitive). A non-{@code - * OPTIONS} request yields {@code 405} with {@code Allow: OPTIONS}; a missing {@code Origin} or - * {@code Access-Control-Request-Method} header yields {@code 400}; any disallowed origin / method - * / header yields {@code 403} with no CORS headers (the browser then blocks the request). - * - *

On success the response is {@code 204 No Content} with {@code Access-Control-Allow-Origin} - * echoing the request's {@code Origin}, the configured method and header allowlists, and {@code - * Vary: Origin} so caches segment by origin. {@code Access-Control-Allow-Credentials} and {@code - * Access-Control-Max-Age} are emitted only when enabled. - * - * @param allowedOrigins exact-match origin allowlist; never {@code null} - * @param allowedMethods non-empty list of methods to advertise in {@code Allow-Methods} - * @param allowedHeaders header allowlist (matched case-insensitively); may be empty (then {@code - * Access-Control-Allow-Headers} is omitted) - * @param allowCredentials whether to emit {@code Access-Control-Allow-Credentials: true} - * @param maxAge {@code Access-Control-Max-Age} value; {@code null} omits the header - */ - public static RequestHandler corsPreflightHandler( - List allowedOrigins, - List allowedMethods, - List allowedHeaders, - boolean allowCredentials, - Duration maxAge) { - Objects.requireNonNull(allowedOrigins, "allowedOrigins must not be null"); - Set origins = Set.copyOf(allowedOrigins); - return corsPreflightHandler( - origins::contains, allowedMethods, allowedHeaders, allowCredentials, maxAge); - } - - /** - * Predicate-based overload of {@link #corsPreflightHandler(List, List, List, boolean, Duration)} - * for callers that need dynamic origin policy (regex, suffix match, config lookup). - */ - public static RequestHandler corsPreflightHandler( - Predicate originAllowed, - List allowedMethods, - List allowedHeaders, - boolean allowCredentials, - Duration maxAge) { - Objects.requireNonNull(originAllowed, "originAllowed must not be null"); - Objects.requireNonNull(allowedMethods, "allowedMethods must not be null"); - Objects.requireNonNull(allowedHeaders, "allowedHeaders must not be null"); - if (allowedMethods.isEmpty()) { - throw new IllegalArgumentException("allowedMethods must not be empty"); - } - if (maxAge != null && (maxAge.isNegative() || maxAge.getSeconds() > Integer.MAX_VALUE)) { - throw new IllegalArgumentException( - "maxAge must be non-negative and fit in an int number of seconds, got " + maxAge); - } - - String allowMethodsHeader = - allowedMethods.stream().map(Enum::name).collect(Collectors.joining(", ")); - String allowHeadersHeader = String.join(", ", allowedHeaders); - Set headerAllowlistLower = - allowedHeaders.stream() - .map(h -> h.toLowerCase(Locale.ROOT)) - .collect(Collectors.toUnmodifiableSet()); - String maxAgeHeader = maxAge == null ? null : Long.toString(maxAge.getSeconds()); - boolean emitAllowHeaders = !allowedHeaders.isEmpty(); - - return req -> { - if (req.method() != OPTIONS) { - return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "OPTIONS"); - } - String origin = requireHeader(req, "Origin"); - String requestMethod = requireHeader(req, "Access-Control-Request-Method"); - if (!isPreflightAllowed( - req, origin, requestMethod, originAllowed, allowedMethods, headerAllowlistLower)) { - return Response.status(HTTP_FORBIDDEN); - } - return buildPreflightSuccess( - origin, - allowMethodsHeader, - allowHeadersHeader, - emitAllowHeaders, - allowCredentials, - maxAgeHeader); - }; - } - - private static String requireHeader(Request req, String name) { - return req.header(name) - .orElseThrow( - () -> new BadRequestException("CORS preflight is missing the " + name + " header")); - } - - private static boolean isPreflightAllowed( - Request req, - String origin, - String requestMethod, - Predicate originAllowed, - List allowedMethods, - Set headerAllowlistLower) { - if (!originAllowed.test(origin)) { - return false; - } - HttpMethod parsed = parseMethodOrNull(requestMethod); - if (parsed == null || !allowedMethods.contains(parsed)) { - return false; - } - return requestedHeadersAllowed(req, headerAllowlistLower); - } - - private static HttpMethod parseMethodOrNull(String s) { - try { - return HttpMethod.parse(s); - } catch (IllegalArgumentException _) { - // Unknown method token — treated as disallowed by the caller. - return null; - } - } - - private static boolean requestedHeadersAllowed(Request req, Set allowedLower) { - String requested = req.header("Access-Control-Request-Headers").orElse(""); - for (String raw : requested.split(",")) { - String h = raw.trim().toLowerCase(Locale.ROOT); - if (h.isEmpty()) { - continue; - } - if (!allowedLower.contains(h)) { - return false; - } - } - return true; - } - - private static Response buildPreflightSuccess( - String origin, - String allowMethodsHeader, - String allowHeadersHeader, - boolean emitAllowHeaders, - boolean allowCredentials, - String maxAgeHeader) { - Response resp = - Response.status(HTTP_NO_CONTENT) - .withHeader("Access-Control-Allow-Origin", origin) - .withHeader("Access-Control-Allow-Methods", allowMethodsHeader) - .withHeader("Vary", "Origin"); - if (emitAllowHeaders) { - resp = resp.withHeader("Access-Control-Allow-Headers", allowHeadersHeader); - } - if (allowCredentials) { - resp = resp.withHeader("Access-Control-Allow-Credentials", "true"); - } - if (maxAgeHeader != null) { - resp = resp.withHeader("Access-Control-Max-Age", maxAgeHeader); - } - return resp; - } - public static ExceptionHandler defaultExceptionHandler() { return t -> switch (t) { diff --git a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java b/src/test/java/com/retailsvc/http/CorsTest.java similarity index 66% rename from src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java rename to src/test/java/com/retailsvc/http/CorsTest.java index 8efa154..059dbc1 100644 --- a/src/test/java/com/retailsvc/http/CorsPreflightHandlerTest.java +++ b/src/test/java/com/retailsvc/http/CorsTest.java @@ -20,7 +20,7 @@ import java.util.function.UnaryOperator; import org.junit.jupiter.api.Test; -class CorsPreflightHandlerTest { +class CorsTest { private static final List METHODS = List.of(GET, POST, PUT, DELETE); private static final List HEADERS = List.of("content-type", "authorization"); @@ -43,9 +43,9 @@ private static Request bare(HttpMethod method) { } @Test - void corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight() { + void preflightHandlerReturns204WithExpectedHeadersOnValidPreflight() { RequestHandler handler = - Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, true, Duration.ofMinutes(10)); + Cors.preflightHandler(ORIGINS, METHODS, HEADERS, true, Duration.ofMinutes(10)); Response resp = handler.handle(preflight("https://app.example.com", "POST", "content-type, authorization")); @@ -62,9 +62,9 @@ void corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight() { } @Test - void corsPreflightHandlerOmitsAllowCredentialsWhenFalse() { + void preflightHandlerOmitsAllowCredentialsWhenFalse() { RequestHandler handler = - Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, Duration.ofMinutes(10)); + Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, Duration.ofMinutes(10)); Response resp = handler.handle(preflight("https://app.example.com", "POST", "content-type")); @@ -72,8 +72,8 @@ void corsPreflightHandlerOmitsAllowCredentialsWhenFalse() { } @Test - void corsPreflightHandlerOmitsMaxAgeWhenNull() { - RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, true, null); + void preflightHandlerOmitsMaxAgeWhenNull() { + RequestHandler handler = Cors.preflightHandler(ORIGINS, METHODS, HEADERS, true, null); Response resp = handler.handle(preflight("https://app.example.com", "POST", "content-type")); @@ -81,9 +81,9 @@ void corsPreflightHandlerOmitsMaxAgeWhenNull() { } @Test - void corsPreflightHandlerEmitsMaxAgeInSecondsWhenSet() { + void preflightHandlerEmitsMaxAgeInSecondsWhenSet() { RequestHandler handler = - Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, Duration.ofSeconds(75)); + Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, Duration.ofSeconds(75)); Response resp = handler.handle(preflight("https://app.example.com", "POST", "content-type")); @@ -91,9 +91,8 @@ void corsPreflightHandlerEmitsMaxAgeInSecondsWhenSet() { } @Test - void corsPreflightHandlerOmitsAllowHeadersWhenListEmpty() { - RequestHandler handler = - Handlers.corsPreflightHandler(ORIGINS, METHODS, List.of(), false, null); + void preflightHandlerOmitsAllowHeadersWhenListEmpty() { + RequestHandler handler = Cors.preflightHandler(ORIGINS, METHODS, List.of(), false, null); Response resp = handler.handle(preflight("https://app.example.com", "POST", "")); @@ -102,8 +101,8 @@ void corsPreflightHandlerOmitsAllowHeadersWhenListEmpty() { } @Test - void corsPreflightHandlerRejectsNonOptionsWith405AndAllowOptions() { - RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + void preflightHandlerRejectsNonOptionsWith405AndAllowOptions() { + RequestHandler handler = Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, null); Response resp = handler.handle(bare(GET)); @@ -112,8 +111,8 @@ void corsPreflightHandlerRejectsNonOptionsWith405AndAllowOptions() { } @Test - void corsPreflightHandlerRejectsMissingOriginWith400() { - RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + void preflightHandlerRejectsMissingOriginWith400() { + RequestHandler handler = Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, null); Request noOrigin = preflight(null, "POST", "content-type"); assertThatThrownBy(() -> handler.handle(noOrigin)) @@ -122,8 +121,8 @@ void corsPreflightHandlerRejectsMissingOriginWith400() { } @Test - void corsPreflightHandlerRejectsMissingRequestMethodWith400() { - RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + void preflightHandlerRejectsMissingRequestMethodWith400() { + RequestHandler handler = Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, null); Request noRequestMethod = preflight("https://app.example.com", null, "content-type"); assertThatThrownBy(() -> handler.handle(noRequestMethod)) @@ -132,8 +131,8 @@ void corsPreflightHandlerRejectsMissingRequestMethodWith400() { } @Test - void corsPreflightHandlerRejectsDisallowedOriginWith403() { - RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + void preflightHandlerRejectsDisallowedOriginWith403() { + RequestHandler handler = Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, null); Response resp = handler.handle(preflight("https://evil.example.com", "POST", "content-type")); @@ -142,9 +141,8 @@ void corsPreflightHandlerRejectsDisallowedOriginWith403() { } @Test - void corsPreflightHandlerRejectsDisallowedMethodWith403() { - RequestHandler handler = - Handlers.corsPreflightHandler(ORIGINS, List.of(GET), HEADERS, false, null); + void preflightHandlerRejectsDisallowedMethodWith403() { + RequestHandler handler = Cors.preflightHandler(ORIGINS, List.of(GET), HEADERS, false, null); Response resp = handler.handle(preflight("https://app.example.com", "DELETE", "content-type")); @@ -152,9 +150,9 @@ void corsPreflightHandlerRejectsDisallowedMethodWith403() { } @Test - void corsPreflightHandlerRejectsDisallowedHeaderWith403() { + void preflightHandlerRejectsDisallowedHeaderWith403() { RequestHandler handler = - Handlers.corsPreflightHandler(ORIGINS, METHODS, List.of("content-type"), false, null); + Cors.preflightHandler(ORIGINS, METHODS, List.of("content-type"), false, null); Response resp = handler.handle(preflight("https://app.example.com", "POST", "x-secret")); @@ -162,8 +160,8 @@ void corsPreflightHandlerRejectsDisallowedHeaderWith403() { } @Test - void corsPreflightHandlerRejectsUnknownMethodTokenWith403() { - RequestHandler handler = Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, null); + void preflightHandlerRejectsUnknownMethodTokenWith403() { + RequestHandler handler = Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, null); Response resp = handler.handle(preflight("https://app.example.com", "BOGUS", "content-type")); @@ -171,9 +169,9 @@ void corsPreflightHandlerRejectsUnknownMethodTokenWith403() { } @Test - void corsPreflightHandlerMatchesHeadersCaseInsensitively() { + void preflightHandlerMatchesHeadersCaseInsensitively() { RequestHandler handler = - Handlers.corsPreflightHandler( + Cors.preflightHandler( ORIGINS, METHODS, List.of("Content-Type", "Authorization"), false, null); Response resp = @@ -183,10 +181,9 @@ void corsPreflightHandlerMatchesHeadersCaseInsensitively() { } @Test - void corsPreflightHandlerEchoesOriginAndIncludesVary() { + void preflightHandlerEchoesOriginAndIncludesVary() { Predicate anyExampleOrigin = o -> o.endsWith(".example.com"); - RequestHandler handler = - Handlers.corsPreflightHandler(anyExampleOrigin, METHODS, HEADERS, false, null); + RequestHandler handler = Cors.preflightHandler(anyExampleOrigin, METHODS, HEADERS, false, null); Response resp = handler.handle(preflight("https://tenant-7.example.com", "POST", "content-type")); @@ -198,9 +195,9 @@ void corsPreflightHandlerEchoesOriginAndIncludesVary() { } @Test - void corsPreflightHandlerListOverloadDelegatesToPredicateBehaviour() { + void preflightHandlerListOverloadDelegatesToPredicateBehaviour() { RequestHandler list = - Handlers.corsPreflightHandler( + Cors.preflightHandler( List.of("https://a.example.com", "https://b.example.com"), METHODS, HEADERS, @@ -215,61 +212,57 @@ void corsPreflightHandlerListOverloadDelegatesToPredicateBehaviour() { } @Test - void corsPreflightHandlerRejectsNullOriginList() { + void preflightHandlerRejectsNullOriginList() { assertThatThrownBy( - () -> Handlers.corsPreflightHandler((List) null, METHODS, HEADERS, false, null)) + () -> Cors.preflightHandler((List) null, METHODS, HEADERS, false, null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("allowedOrigins"); } @Test - void corsPreflightHandlerRejectsNullOriginPredicate() { + void preflightHandlerRejectsNullOriginPredicate() { assertThatThrownBy( - () -> - Handlers.corsPreflightHandler( - (Predicate) null, METHODS, HEADERS, false, null)) + () -> Cors.preflightHandler((Predicate) null, METHODS, HEADERS, false, null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("originAllowed"); } @Test - void corsPreflightHandlerRejectsNullMethods() { - assertThatThrownBy(() -> Handlers.corsPreflightHandler(ORIGINS, null, HEADERS, false, null)) + void preflightHandlerRejectsNullMethods() { + assertThatThrownBy(() -> Cors.preflightHandler(ORIGINS, null, HEADERS, false, null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("allowedMethods"); } @Test - void corsPreflightHandlerRejectsEmptyMethods() { + void preflightHandlerRejectsEmptyMethods() { List empty = List.of(); - assertThatThrownBy(() -> Handlers.corsPreflightHandler(ORIGINS, empty, HEADERS, false, null)) + assertThatThrownBy(() -> Cors.preflightHandler(ORIGINS, empty, HEADERS, false, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("allowedMethods"); } @Test - void corsPreflightHandlerRejectsNullHeaders() { - assertThatThrownBy(() -> Handlers.corsPreflightHandler(ORIGINS, METHODS, null, false, null)) + void preflightHandlerRejectsNullHeaders() { + assertThatThrownBy(() -> Cors.preflightHandler(ORIGINS, METHODS, null, false, null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("allowedHeaders"); } @Test - void corsPreflightHandlerRejectsNegativeMaxAge() { + void preflightHandlerRejectsNegativeMaxAge() { Duration negative = Duration.ofSeconds(-1); - assertThatThrownBy( - () -> Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, negative)) + assertThatThrownBy(() -> Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, negative)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("maxAge"); } @Test - void corsPreflightHandlerRejectsOverflowingMaxAge() { + void preflightHandlerRejectsOverflowingMaxAge() { Duration tooBig = Duration.ofSeconds((long) Integer.MAX_VALUE + 1); - assertThatThrownBy( - () -> Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, false, tooBig)) + assertThatThrownBy(() -> Cors.preflightHandler(ORIGINS, METHODS, HEADERS, false, tooBig)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("maxAge"); }