diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 343bd63..1c35454 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -12,6 +12,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v7 + - name: Setup Java + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version-file: .java-version + - name: Run pre-commit uses: pre-commit/actions@v3.0.1 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bed54ac..e9661e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: hooks: - id: editorconfig-checker - repo: https://github.com/extenda/pre-commit-hooks - rev: v0.15.0 + rev: v0.16.1 hooks: - id: google-java-formatter - id: commitlint diff --git a/CLAUDE.md b/CLAUDE.md index 70b0896..15ec717 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,20 +26,23 @@ Java 25 is required (see `.java-version`). The server uses thread-per-request wi Request flow when `OpenApiServer` boots (`src/main/java/com/retailsvc/http/OpenApiServer.java`): 1. `HttpServer` is created on a port with a virtual-thread-per-task executor. -2. A single `HttpContext` is registered at `spec.basePath()` (the first `servers[].url` path from the OpenAPI doc). A catch-all `/` context returns 404. -3. Three filters run in order on every request: - - `ExceptionFilter` — wraps the chain; delegates uncaught exceptions to the user-supplied `ExceptionHandler` (default in `Handlers`). - - `RequestPreparationFilter` — reads the raw request body, stashes it as an exchange attribute, runs OpenAPI parameter + body validation via `DefaultValidator`, and stores the resolved `operationId` on the exchange. - - `DispatchHandler` — looks up the `HttpHandler` registered for that `operationId` in the user-supplied map and invokes it. Handler coverage is verified at boot, so the lookup never returns `null`. +2. One `HttpContext` is registered per spec binding at `spec.basePath()` (the first `servers[].url` path from the OpenAPI doc). Unless a binding owns `/`, a catch-all `/` context serves extra routes via `ExtrasRouter` and 404s everything else; `ExceptionFilter` wraps that context only. +3. On a binding context, two filters run in order, then the handler: + - `RequestPreparationFilter` — reads the request body through `RequestBodyReader` (which decodes a registered `Content-Encoding` — gzip is built in — under a size cap), resolves the route, runs OpenAPI parameter + body validation via `DefaultValidator`, and binds the resulting `Request` into the `DispatchHandler.CURRENT` scoped value. It renders its own failures through the `ExceptionHandler` rather than relying on `ExceptionFilter`. + - `SecurityFilter` — enforces the spec's `securitySchemes` / `security`, re-binding the `Request` with resolved principals. It writes its 401/403 responses straight to the exchange. + - `DispatchHandler` — looks up the `RequestHandler` registered for the resolved `operationId` in the user-supplied map and invokes it, applying interceptors and response decorators. Handler coverage is verified at boot, so the lookup never returns `null`. + +Every response except `SecurityFilter`'s rejections is written by `ResponseRenderer`, which is also where response content coding is applied. Key abstractions: - `com.retailsvc.http.spec.Spec` — parsed from a consumer-supplied `Map` via `Spec.from(raw)`. No JSON library dependency in the library itself; callers use Gson, Jackson, SnakeYAML, etc. to produce the map. - Sealed `com.retailsvc.http.spec.schema.Schema` interface with per-kind records (`StringSchema`, `NumberSchema`, `IntegerSchema`, `ArraySchema`, `ObjectSchema`, `BooleanSchema`, `NullSchema`, `AnyOfSchema`, `AllOfSchema`, `OneOfSchema`). Pattern-match dispatch eliminates instanceof chains. -- `com.retailsvc.http.validate.DefaultValidator` — single class using `switch` pattern-match over `Schema` subtypes. Validation failures produce RFC 7807 `application/problem+json` 400 responses. +- `com.retailsvc.http.validate.DefaultValidator` — single class using `switch` pattern-match over `Schema` subtypes. Validation failures produce RFC 9457 `application/problem+json` 400 responses. - `com.retailsvc.http.internal.Router` — two indexes: exact path map and templated path list. Resolves `operationId` + extracted path variables for each request. -- `JsonMapper` — `@FunctionalInterface`; single method `Object mapFrom(byte[])`. Callers supply a lambda (see README). -- `com.retailsvc.http.Request` — static helper; `Request.bytes(exchange)` returns raw body bytes, `Request.parsed(exchange)` returns the `Object` produced by the `JsonMapper`. +- `TypeMapper` — per-media-type request parsing and response writing; registered via `Builder.bodyMapper(...)`, with `GsonTypeMapper` auto-registered when Gson is on the classpath. +- `com.retailsvc.http.Request` — an immutable record-like carrier built from primitives (body bytes, path parameters, raw query string, a header lookup function), never the `HttpExchange`. `bytes()` returns the decoded body, `parsed()` the object produced by the `TypeMapper`. +- `com.retailsvc.http.ContentCoding` — a pluggable HTTP content coding. gzip is built in (`internal/GzipCoding`); callers register others on the builder, held per direction in `internal/ContentCodings`. `RequestBodyReader` decodes requests under the size cap and `ResponseRenderer` codes responses. See the README's "Content encoding" section for the policy. ## Conventions diff --git a/README.md b/README.md index ff36077..b03cde2 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function - [Body parsers and response writers](#body-parsers-and-response-writers) - [Server configuration](#server-configuration) - [HTTPS](#https) + - [Content encoding](#content-encoding) - [Interceptors and response decorators](#interceptors-and-response-decorators) - [After-response hooks](#after-response-hooks) - [Security](#security) @@ -48,6 +49,8 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function - OpenAPI `securitySchemes` and `security` enforcement (`apiKey`, `http bearer`, `http basic`), with an opt-out for sidecar / gateway authentication - RFC 9457 `application/problem+json` validation errors with an `errors[]` array of JSON-Pointers to the failing locations +- Transparent gzip: request bodies are inflated under a zip-bomb ceiling, responses are compressed + when the client accepts it and the payload is worth it - Built on the JDK's native `HttpServer` with thread-per-request behaviour using virtual threads ## Maven artifact @@ -459,6 +462,83 @@ explicitly — it isn't signed by a public CA. - TLS protocol / cipher overrides (JDK defaults apply: TLS 1.2 and 1.3) - Serving HTTP and HTTPS from one `OpenApiServer` instance +### Content encoding + +gzip is handled in both directions, with no configuration required. + +**Requests.** A body sent with `Content-Encoding: gzip` is inflated before OpenAPI validation runs, +so the validator, your `TypeMapper` and your handler all see plain bytes. `identity` is accepted as +the no-op it is. A coding the server has not registered — `br`, say — or two codings stacked is +rejected with `415 Unsupported Media Type`, and a corrupt or truncated body with `400 Bad Request`. + +Once a body is inflated it no longer matches the headers that described it, so `Content-Encoding` is +hidden from `Request.header(...)` and `Content-Length` reports the inflated size. + +Inflation runs under a ceiling, because a few compressed kilobytes can expand into gigabytes: + +```java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .maxDecompressedRequestBytes(32 * 1024 * 1024) // raises the 10 MiB default; over it, 413 + .build(); +``` + +Note this bounds the *inflated* size of a coded body, whatever the coding. It is not a request size +limit — a body that arrives uncompressed is read in full, as it always has been. + +**Responses.** A body is gzipped when the client sends `Accept-Encoding: gzip`, the media type is +text-shaped (`text/*`, `application/json`, `application/xml`, `application/yaml`, and the `+json` / +`+xml` / `+yaml` structured suffixes), and it is at least 1 KiB. Below that the coding costs more +than it saves; `application/octet-stream`, images and other already-compressed media are never +coded, and neither is `text/event-stream`, which has to stay unbuffered. + +```java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .minCompressibleResponseBytes(4096) // raises the 1 KiB default; 0 compresses every eligible body + .build(); +``` + +There is no on/off flag. If a proxy in front of you already terminates compression, set the +threshold above anything this server returns. + +`Vary: Accept-Encoding` is set whenever a body *could* have been coded, not only when it was, so +shared caches keep the two forms apart. It is merged into any `Vary` your handler already set. +A handler that sets its own `Content-Encoding` is left alone, and so is a payload the coding +fails to shrink. Statuses that carry no content never get a coding. + +Streamed responses (`Response.stream(...)`) are coded as they are written. A length declared by +the sized overload describes the uncoded body, so a coded stream goes out chunked; a stream of +unknown length is coded regardless of the threshold, since measuring it would defeat streaming it. +For the same reason a `HEAD` whose `GET` would be compressed omits `Content-Length` rather than +advertising the uncoded length. + +**Other codings.** The library ships gzip only, and so carries no compression dependency. To offer +another, implement `ContentCoding` and register it: + +```java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .contentCoding(new ZstdCoding()) // your ContentCoding implementation + .build(); +``` + +The client's weights pick the coding; on a tie, registered codings win over gzip, in registration +order. `decode` and `encode` wrap streams rather than whole bodies, so a decoder that reads lazily +is held to `maxDecompressedRequestBytes` without doing anything itself. `requestContentCoding` and +`responseContentCoding` register one direction only; a request coded with a response-only coding +gets 415. Tokens must be lower-case, and `gzip`, `x-gzip`, `identity` and `*` are reserved. + +**Not in this release** (each can land later without breaking the API): + +- the `Accept-Encoding` response header RFC 9110 recommends alongside a 415 +- compression of the `401`/`403` bodies produced by security scheme enforcement — those bypass the + renderer and are well under any sensible threshold +- per-route or per-operation opt-out + ### Graceful shutdown `OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the given @@ -1218,6 +1298,12 @@ A few things worth keeping in mind when reading this: JDK `HttpExchange`. A future enhancement could plug in a higher-throughput backend (Jetty, Helidon Níma, Netty) by writing a new adapter behind `com.retailsvc.http.internal` while leaving handlers untouched. +- **gzip changes what an `ETag` identifies.** The library sets none, but a handler that sets a + strong `ETag` would use one entity tag for both the coded and uncoded forms of a body. Use a weak + tag (`W/"..."`), or set `Content-Encoding` yourself to opt that response out of compression. +- **A handler that throws mid-stream yields a valid gzip trailer.** Closing the coded stream + finishes the gzip member, so a client sees a complete-looking short body rather than the framing + error a truncated chunked response would have produced. - **Per-request state uses `ScopedValue`** (Java 25, JEP 506). This matters if a handler offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. diff --git a/docs/plans/dynamic-discovering-piglet.md b/docs/plans/dynamic-discovering-piglet.md new file mode 100644 index 0000000..6179dc9 --- /dev/null +++ b/docs/plans/dynamic-discovering-piglet.md @@ -0,0 +1,368 @@ +# gzip content encoding + +## Context + +The library has no notion of `Content-Encoding` or `Accept-Encoding` today — a repo-wide grep +returns zero hits. Every request body is read verbatim with `exchange.getRequestBody().readAllBytes()` +and every response body is written verbatim by `ResponseRenderer`. A client that gzips its payload +gets a schema-validation failure on binary garbage, and JSON responses always go out uncompressed +even when the client advertises gzip support. + +This adds transparent gzip in both directions: + +- **Requests** — a body sent with `Content-Encoding: gzip` is inflated before OpenAPI body + validation runs, so the validator, the `TypeMapper`s and handlers see plain bytes and need no + changes. Inflation is bounded so a small compressed payload cannot expand into an OOM (zip bomb). +- **Responses** — a body is gzipped when the client sends `Accept-Encoding: gzip`, the media type is + text-shaped, and the payload clears a size threshold. + +Both directions are on by default; the two numeric knobs are builder options. + +## Design decisions + +| Decision | Choice | +|---|---| +| Enablement | On by default, no on/off flag. Raising `minimumGzipResponseBytes` is the escape hatch. | +| Codings | `gzip` (and the legacy `x-gzip` alias) only. `identity` is a legal no-op, not an error. | +| Unknown request coding (`br`, `deflate`, two stacked codings) | `415 Unsupported Media Type` | +| Malformed / truncated gzip stream | `400 Bad Request`, problem+json, `ZipException` as cause | +| Over the inflation cap | `413 Content Too Large` | +| `Accept-Encoding` absent, or `gzip;q=0` | No compression. `*` with q>0 does compress. | +| Response policy | Compressible media type **and** body ≥ threshold (default 1 KiB) | +| Inflation cap | Default 10 MiB, `maxDecompressedRequestBytes(long)` | +| Uncompressed request bodies | Stay unbounded, as today — pre-existing gap, out of scope | +| Streaming responses | Compressed too; `BodyWriter.Sized` degrades to chunked | +| `Request.header("Content-Encoding")` | Hidden after decoding; `Content-Length` reports the *inflated* length | +| `SecurityFilter` 401/403 | Left uncompressed — bypasses the renderer, bodies are ~120 bytes | + +## Branch + +`feat/gzip-content-encoding`, cut from `master`. A plain branch, not a worktree — the Java LSP and +the SonarLint MCP server are both blind to `.claude/worktrees/`. + +--- + +## Architecture + +Two shared, immutable collaborators built once in `OpenApiServer` and threaded into the filters, +exactly as `ResponseRenderer` is threaded today from `OpenApiServer.java:95`: + +- `RequestBodyReader` (new) — inbound decode, owns the inflation cap. +- `ResponseRenderer` (existing) — outbound encode, gains the size threshold. + +Putting the encode step inside `ResponseRenderer.render` covers five of the six response paths: +normal dispatch, handler exceptions, pre-request 404/405/400, extras routes (`/health`, spec +serving, CORS preflight) and the catch-all `/` 404. The sixth — `SecurityFilter.renderRejection` +(`SecurityFilter.java:111-137`) — writes to the exchange directly and stays uncompressed by design. + +A `ResponseDecorator` would have been the wrong hook: decorators run only in `DispatchHandler` +(`DispatchHandler.java:47-49`), so every error body, the health endpoint and all 404s would be missed. + +## New files + +### `internal/AcceptEncodingHeader.java` + +```java +/** Parses {@code Accept-Encoding} request header values (RFC 9110 §12.5.3). */ +public final class AcceptEncodingHeader { + public static boolean acceptsGzip(String header); +} +``` + +Split on `,`; per token strip `;` params; lowercase. `q` defaults to `1.0`, and an unparsable `q` is +treated as `1.0` (lenient — no security consequence). Track `gzipQ` (from `gzip` or `x-gzip`) and +`starQ`; return `gzipQ != null ? gzipQ > 0 : starQ != null && starQ > 0`. An explicit `gzip;q=0` is a +refusal and beats a positive `*`. + +### `internal/ContentEncodingHeader.java` + +```java +/** Classifies a request {@code Content-Encoding} into the codings the server can decode. */ +public final class ContentEncodingHeader { + public enum Coding { NONE, GZIP, UNSUPPORTED } + public static Coding parse(String header); +} +``` + +`null`/blank/`identity` → `NONE`; a single `gzip`/`x-gzip` (optionally with `identity`) → `GZIP`; +anything else, including two real stacked codings → `UNSUPPORTED`. + +### `internal/RequestBodyReader.java` + +```java +/** Reads the request body, transparently inflating gzip under a hard cap on decompressed size. */ +public final class RequestBodyReader { + public static final long DEFAULT_MAX_DECOMPRESSED_BYTES = 10L * 1024 * 1024; + + public RequestBodyReader(long maxDecompressedBytes); // IllegalArgumentException if <= 0 + public Body read(HttpExchange exchange) throws IOException; + + public record Body(byte[] bytes, boolean decoded) { + public UnaryOperator headerLookup(Headers headers); + } +} +``` + +Read the raw bytes first, then inflate from a `ByteArrayInputStream`. Inflating the exchange stream +directly would make `new GZIPInputStream(...)` throw `EOFException` on an empty body, forcing a peek +to tell "no body" from "truncated body". Peak memory is compressed + inflated, and the plain path +already buffers the whole body. + +Inflate in a loop with a running counter; trip the cap **mid-inflate** rather than after, so a bomb +never materialises. Catch **only `ZipException | EOFException`** and rethrow as +`BadRequestException(HTTP_BAD_REQUEST, "malformed gzip request body", e)` — a genuine socket +`IOException` must stay an `IOException` and render 500, not 400. + +`headerLookup` returns `headers::getFirst` untouched when nothing was decoded. After inflating it +hides `Content-Encoding` and reports the inflated `Content-Length`: the stored value is the +*compressed* size, which would be a lie sitting next to `bytes()`. + +`BadRequestException` already enforces a 4xx status (`BadRequestException.java:47`) and +`Handlers.defaultExceptionHandler()` (`Handlers.java:67-77`) renders it as problem+json with the +supplied status and logs the cause at DEBUG — 413 and 415 need no new error plumbing. + +### `internal/ResponseCompression.java` + +```java +/** Response content-coding policy and the gzip primitives the renderer uses. */ +public final class ResponseCompression { + public static boolean isCompressible(String contentType); + public static byte[] gzip(byte[] body) throws IOException; + public static OutputStream gzipStream(OutputStream out) throws IOException; +} +``` + +`text/*` **except `text/event-stream`** (gzip buffers through a `Deflater`; wrapping SSE destroys +per-event flushing and hangs the client), the `+json` / `+xml` / `+yaml` structured suffixes, plus +`application/json`, `application/xml`, `application/yaml`, `application/x-yaml`, +`application/javascript`, `application/x-ndjson`. `application/problem+json` and `image/svg+xml` fall +out of the suffix rules; `application/octet-stream` — the `byte[]` default — is excluded. + +**Null guard is load-bearing:** `ContentTypeHeader.mediaType(null)` returns `application/json`, so a +null content type must be rejected *before* that call, or every untyped stream looks like JSON. + +## Modified files + +### `internal/ResponseRenderer.java` — the bulk of the work + +Two-arg constructor `(Map, long minimumGzipBytes)` plus a retained 1-arg +overload delegating to it with `DEFAULT_MINIMUM_GZIP_BYTES = 1024`, so the three existing test +call sites compile untouched. + +`render` gains a third branch so the null-body case can still do header work: +`renderEmpty` / `renderStream` / `renderBytes`. + +Three private helpers: + +- `bodyAllowed(int status)` — false for 1xx, 204, 205, 206, 304. Those must never carry a coding. +- `wouldCompress(exchange, headers, length)` — no existing `Content-Encoding`, `length >= threshold`, + and `AcceptEncodingHeader.acceptsGzip(...)`. +- `addVary(headers)` — appends `Accept-Encoding` to any existing `Vary` (the CORS preflight handler + already emits `Vary: Origin`) as one merged field line, skipping if `*` or `Accept-Encoding` is + already listed. + +`renderBytes` inserts one `maybeCompress` call between the Content-Type default and +`sendResponseHeaders`; `Content-Length` stays correct for free because `:71` derives it from +`bytes.length`. If gzip comes out no smaller than the input, keep the original bytes and set no +header. + +`renderStream` threshold-checks `BodyWriter.Sized.length()`; a `Chunked` body has no knowable length +and maps to "always over threshold" — buffering to measure would defeat streaming. When compressing, +send `sendResponseHeaders(status, 0)` (chunked), because `Sized.length()` is the *uncompressed* +length and must not go on the wire. + +`renderEmpty` is where the HEAD fix lands: when a GET would have been compressed, drop the +hand-declared `Content-Length` (RFC 9110 §9.3.2 permits omitting fields "determined only while +generating the content"). Never set `Content-Encoding` on a bodiless response — it would promise an +encoding for a body the client may later fetch with a different `Accept-Encoding`. + +**Ordering constraint:** check `isCompressible(contentType) && bodyAllowed(status)` before reading +`exchange.getRequestHeaders()`. Cheap checks first anyway — but see the test-stub note in Task 7. + +### `internal/RequestPreparationFilter.java` + +New field + 8th constructor parameter `RequestBodyReader bodyReader` (the constructor already carries +`@SuppressWarnings("java:S107")`). At `:97`, `bodyReader.read(exchange)`; at `:124`, +`body.headerLookup(headers)` in place of `headers::getFirst`. The read stays before routing, so +404/405 behaviour is unchanged. No exception wiring: `BadRequestException` is a `RuntimeException` +and `doFilter`'s catch at `:72-76` already renders it. + +### `internal/ExtrasRouter.java` + +Same substitution at `:58` and `:68`, plus a 3rd constructor parameter. + +### `internal/ProblemDetail.java` + +`TITLES` has no 413 entry, so a 413 would render `"title": "Bad Request"`. Add +`413 -> "Content Too Large"`. **`Map.of` caps at 10 pairs and `TITLES` has exactly 10** — the 11th +forces a rewrite to `Map.ofEntries(entry(...), ...)`. + +### `Handlers.java` + +**No change.** The HEAD fix lives in `renderEmpty`, which fixes *any* handler that hand-sets +`Content-Length` on a null body, not just `resourceHandler`. + +### `OpenApiServer.java` + +`HandlerConfig` (`:61-67`) gains `long maxDecompressedRequestBytes, long minimumGzipResponseBytes`. +Builder fields default from the two internal constants; two fluent setters modelled on +`shutdownTimeoutSeconds` (`:403-410`) — `maxDecompressedRequestBytes` requires `> 0`, +`minimumGzipResponseBytes` requires `>= 0`. At `:95`, build both collaborators and thread +`bodyReader` through `wireBindings` → `wireBinding` and `wireExtras`, parallel to `renderer`. + +--- + +## Tasks + +Test-first throughout: write the named tests, watch them fail, then implement. Check off each step +as it completes. + +### Task 1 — header parsing + +- [x] **Step 1** `internal/AcceptEncodingHeaderTest` — `nullHeaderIsNotAccepted`, + `blankHeaderIsNotAccepted`, `plainGzipIsAccepted`, `gzipAmongOtherCodingsIsAccepted`, + `caseInsensitiveGzipIsAccepted`, `xGzipIsAccepted`, `explicitZeroQValueIsRefused`, + `positiveQValueIsAccepted`, `wildcardIsAccepted`, `wildcardWithZeroQValueIsRefused`, + `explicitGzipBeatsWildcardRefusal`, `identityOnlyIsNotAccepted`, + `malformedQValueIsTreatedAsAccepted`. Then implement. +- [x] **Step 2** `internal/ContentEncodingHeaderTest` — `nullHeaderIsNone`, `emptyHeaderIsNone`, + `identityIsNone`, `gzipIsGzip`, `xGzipIsGzip`, `mixedCaseGzipIsGzip`, `gzipWithIdentityIsGzip`, + `brotliIsUnsupported`, `deflateIsUnsupported`, `stackedCodingsAreUnsupported`. Then implement. + +### Task 2 — bounded request inflation + +- [x] **Step 3** `internal/RequestBodyReaderTest` (mocked `HttpExchange`, per the + `ExtrasRouterTest` pattern) — `plainBodyIsReturnedUnchanged`, `gzipBodyIsInflated`, + `emptyGzipBodyIsReturnedEmpty`, `unsupportedCodingThrows415`, `oversizedInflatedBodyThrows413`, + `malformedGzipThrows400WithCause`, `truncatedGzipThrows400`, + `decodedBodyHidesContentEncodingHeader`, `decodedBodyReportsInflatedContentLength`, + `plainBodyKeepsOriginalHeaderLookup`, `constructorRejectsNonPositiveCap`. Then implement. +- [x] **Step 4** `internal/ProblemDetailTest` — `contentTooLargeHasItsOwnTitle`. Then convert + `TITLES` to `Map.ofEntries` and add the 413 row. +- [x] **Step 5** Wire into `RequestPreparationFilter` and `ExtrasRouter`: + `gzipRequestBodyIsInflatedBeforeValidation`, `unsupportedRequestCodingIsRejectedBeforeRouting`, + `gzipRequestBodyIsInflatedForExtraRoutes`. Their test factories gain a `RequestBodyReader` + argument — the compile break is the expected first failure. + +### Task 3 — response compression + +- [x] **Step 6** `internal/ResponseCompressionTest` — `nullContentTypeIsNotCompressible`, + `jsonIsCompressible`, `problemJsonIsCompressible`, `yamlIsCompressible`, + `textPlainWithCharsetIsCompressible`, `xmlSuffixIsCompressible`, + `octetStreamIsNotCompressible`, `imagePngIsNotCompressible`, `eventStreamIsNotCompressible`, + `gzipRoundTripsBytes`, `gzipStreamRoundTripsBytes`. Then implement. +- [x] **Step 7** `internal/ResponseRendererTest` — the repo's **first direct renderer test**, so it + starts with baseline coverage of behaviour it is about to change + (`writesBytesWithContentLength`, `writesNullBodyWithMinusOne`, `writesSizedStreamWithLength`), + then: `compressesJsonBodyOverThreshold`, `setsContentEncodingGzipWhenCompressed`, + `sentContentLengthMatchesCompressedPayload`, `skipsCompressionBelowThreshold`, + `skipsCompressionForOctetStream`, `skipsCompressionWithoutAcceptEncoding`, + `skipsCompressionWhenGzipRefusedByQValue`, + `skipsCompressionWhenHandlerAlreadySetContentEncoding`, `addsVaryEvenWhenNotCompressed`, + `appendsVaryToExistingValue`, `doesNotDuplicateVary`, `neverCompressesNoContentResponses`, + `fallsBackToPlainBytesWhenGzipIsLarger`, `leavesUncompressedBodyByteIdentical`. + Then implement `bodyAllowed`, `wouldCompress`, `addVary`, `maybeCompress`. + + Add `when(exchange.getRequestHeaders()).thenReturn(new Headers())` to + `DispatchHandlerTest.stubExchange()` — it stubs only `getResponseHeaders()` today, so an + unstubbed `getRequestHeaders()` returns null. Fixing the stub is honest and removes a fragile + dependency on evaluation order in main code. +- [x] **Step 8** Streaming and null bodies, same test class: + `compressesChunkedStreamWhenAcceptEncodingPresent`, + `degradesSizedStreamToChunkedWhenCompressed`, `skipsCompressionForSizedStreamBelowThreshold`, + `skipsCompressionForNullContentTypeStream`, + `stripsContentLengthOnNullBodyWhenGetWouldCompress`, + `keepsContentLengthOnNullBodyWhenClientDoesNotAcceptGzip`, + `keepsContentLengthOnNullBodyForNonCompressibleType`. + Then implement `renderStream` and `renderEmpty`. + +### Task 4 — builder + +- [x] **Step 9** `OpenApiServerBuilderTest` — `maxDecompressedRequestBytesRejectsZero`, + `maxDecompressedRequestBytesRejectsNegative`, `minimumGzipResponseBytesRejectsNegative`. + Then add the two setters, the `HandlerConfig` fields and the `build()` wiring. + +### Task 5 — end to end + +- [x] **Step 10** `GzipIT` extending `ServerBaseTest`. Reuses the existing `/openapi.json` fixture + with runtime handler overrides — **no new spec files**. `text-echo` (`POST /text-echo`, + `text/plain`, schema `{"type":"string"}`, no `maxLength`) echoes the body, so one call + exercises both directions. Private `gzip(byte[])` / `gunzip(byte[])` helpers; requests built + manually as in `NonJsonBodyIT`; responses read with `BodyHandlers.ofByteArray()`. Tests: + `largeJsonResponseIsGzippedWhenClientAcceptsGzip`, `smallJsonResponseIsNotGzipped`, + `responseIsNotGzippedWithoutAcceptEncoding`, `streamedSpecResourceIsGzippedAndChunked`, + `headOmitsContentLengthWhenGetWouldBeCompressed`, + `headKeepsContentLengthWithoutAcceptEncoding`, `gzippedRequestBodyIsDecompressed`, + `handlerDoesNotSeeContentEncodingHeader`, `unsupportedRequestEncodingReturns415`, + `malformedGzipRequestReturns400`, `oversizedGzipRequestReturns413` (server built with + `maxDecompressedRequestBytes(1024)`, posting ~4 KiB gzipped — keeps it fast and covers the + builder option). + + `java.net.http.HttpClient` neither sends `Accept-Encoding` nor auto-decompresses, so + `responseIsNotGzippedWithoutAcceptEncoding` is the regression guard for the whole existing + IT suite. + +### Task 6 — docs + +- [x] **Step 11** README: `### Request decompression` and `### Response compression` under + `## Server configuration`, a TOC entry, a `## Highlights` bullet, and a **"Not in this + release"** list matching the HTTPS section's convention — brotli/deflate/zstd, + `Accept-Encoding` on 415 responses (RFC 9110 §15.5.16 SHOULD; `BadRequestException` carries no + headers), compression of `SecurityFilter` 401/403 bodies, per-route opt-out. Plus `Caveats` + bullets: the cap bounds *inflated* bytes only and is not a request size limit; a strong `ETag` + set by a handler now spans two byte streams; a handler that throws mid-stream yields a valid + gzip trailer over truncated content rather than a framing error. +- [x] **Step 12** Correct the stale request-flow description in `CLAUDE.md` — it describes three + filters including `ExceptionFilter` on the spec context, exchange-attribute body stashing and + a `Request.bytes(exchange)` static helper, none of which match the current code, and it + mentions neither `SecurityFilter` nor `ExtrasRouter`. + +--- + +## Verification + +```bash +mvn test +mvn test -Dtest=ResponseRendererTest +mvn verify # Failsafe runs GzipIT; JaCoCo at target/site/jacoco/ +mvn verify -Dit.test=GzipIT +``` + +What to look for: + +- **Zero regressions in the pre-existing ITs.** This is the load-bearing signal that on-by-default + compression is invisible to `java.net.http.HttpClient`. If `OpenApiServerIT`, `SecurityIT`, + `NonJsonBodyIT` or `ExtraHandlersIT` change behaviour, the gate is wrong. +- No JUL warning `sendResponseHeaders: being invoked with a content length for a HEAD request` in + the Failsafe output — that string means a HEAD request reached `renderBytes`. +- `target/site/jacoco/` — the four new internal classes near-fully covered, and `ResponseRenderer` + coverage sharply up now that it has a direct test. + +Manually against the example server: + +```bash +mvn test-compile exec:java -Dexec.mainClass=com.retailsvc.http.start.ServerLauncher \ + -Dexec.classpathScope=test + +# small body: Vary only, no Content-Encoding +curl -sD- -o /dev/null -H 'Accept-Encoding: gzip' http://localhost:8080/api/v1/data + +# request decompression — expect the echoed text back +printf 'hello gzip' | gzip | curl -s --data-binary @- \ + -H 'Content-Type: text/plain' -H 'Content-Encoding: gzip' \ + http://localhost:8080/api/v1/text-echo + +# unsupported coding — expect 415 problem+json +curl -si -H 'Content-Encoding: br' -H 'Content-Type: text/plain' \ + --data-binary 'x' http://localhost:8080/api/v1/text-echo +``` + +`k6 run acceptance/k6/script.js` should pass unchanged: it asserts +`r.headers['Content-Type'] === 'application/json'`, which compression never touches, and every +payload it exercises is far under 1 KiB. + +`mvn sortpom:sort` is not needed — no new dependencies, `java.util.zip` is in `java.base`. + +Before pushing: analyse every touched file with the SonarLint MCP server and fix any new issue in +the same branch. Watch for `java:S6218` on the `Body` record (array component) and `java:S107` on the +widened `HandlerConfig`. diff --git a/src/main/java/com/retailsvc/http/ContentCoding.java b/src/main/java/com/retailsvc/http/ContentCoding.java new file mode 100644 index 0000000..c65a1b4 --- /dev/null +++ b/src/main/java/com/retailsvc/http/ContentCoding.java @@ -0,0 +1,53 @@ +package com.retailsvc.http; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Set; + +/** + * An HTTP content coding (RFC 9110 §8.4.1) that the server decodes on requests and applies to + * responses. The library ships {@code gzip}; anything else — {@code zstd}, {@code br}, {@code + * deflate} — is supplied by the caller and registered on {@link + * OpenApiServer.Builder#contentCoding(ContentCoding)}, which keeps this library free of any + * compression dependency. + * + *

One instance serves every request, so implementations must be immutable and safe for + * concurrent use. + * + *

Both methods wrap a stream rather than convert a whole body: return a stream that codes as it + * is read or written. For {@link #decode} this is what bounds the work — the server reads at most + * {@code maxDecompressedRequestBytes} from the stream you return, so a lazy decoder is protected + * from decompression bombs without doing anything, while one that expands the whole body up front + * has already spent what that limit exists to protect. + */ +public interface ContentCoding { + + /** + * The {@code Content-Encoding} and {@code Accept-Encoding} token, such as {@code zstd}. Must be a + * lower-case RFC 9110 token; {@code gzip}, {@code x-gzip}, {@code identity} and {@code *} are + * reserved. + */ + String token(); + + /** + * Further tokens that mean this same coding on the wire, such as a legacy {@code x-} name. + * Recognised on input only; a coded response always announces {@link #token()}. + */ + default Set aliases() { + return Set.of(); + } + + /** + * Wraps a coded request body in a stream of the decoded bytes. The argument is always held in + * memory, so a read failure means the body is malformed: throw {@link IOException} and the server + * answers 400. Any other exception is treated as a fault in the coding. + */ + InputStream decode(InputStream coded) throws IOException; + + /** + * Wraps a response stream so that everything written to the returned stream reaches {@code sink} + * coded. Closing the returned stream must finish the coded payload and close {@code sink}. + */ + OutputStream encode(OutputStream sink) throws IOException; +} diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index e1823b8..89f0969 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -4,11 +4,13 @@ import static java.util.Objects.requireNonNull; import static java.util.concurrent.Executors.newThreadPerTaskExecutor; +import com.retailsvc.http.internal.ContentCodings; import com.retailsvc.http.internal.DispatchHandler; import com.retailsvc.http.internal.ExceptionFilter; import com.retailsvc.http.internal.ExtrasRouter; import com.retailsvc.http.internal.FormTypeMapper; import com.retailsvc.http.internal.PemSslContext; +import com.retailsvc.http.internal.RequestBodyReader; import com.retailsvc.http.internal.RequestPreparationFilter; import com.retailsvc.http.internal.ResponseRenderer; import com.retailsvc.http.internal.SecurityFilter; @@ -64,7 +66,9 @@ record HandlerConfig( ExceptionHandler exceptionHandler, Map extras, boolean externalAuth, - List afterHooks) {} + List afterHooks, + RequestBodyReader bodyReader, + ResponseRenderer renderer) {} OpenApiServer( List bindings, @@ -83,7 +87,6 @@ record HandlerConfig( requireNonNull(bodyMappers, "bodyMappers must not be null"); long t0 = System.currentTimeMillis(); - ExceptionHandler exceptionHandler = handlerConfig.exceptionHandler(); InetSocketAddress socketAddress = (bindAddress == null) @@ -92,10 +95,8 @@ record HandlerConfig( this.httpServer = createHttpServer(socketAddress, sslContext); httpServer.setExecutor(newThreadPerTaskExecutor(ofVirtual().name("http-", 0).factory())); - ResponseRenderer renderer = new ResponseRenderer(bodyMappers); - boolean anyBindingAtRoot = - wireBindings(httpServer, bindings, bodyMappers, handlerConfig, exceptionHandler, renderer); - wireExtras(httpServer, anyBindingAtRoot, handlerConfig.extras(), exceptionHandler, renderer); + boolean anyBindingAtRoot = wireBindings(httpServer, bindings, bodyMappers, handlerConfig); + wireExtras(httpServer, anyBindingAtRoot, handlerConfig); httpServer.start(); this.shutdownTimeoutSeconds = shutdownTimeoutSeconds; @@ -112,33 +113,26 @@ private static HttpServer createHttpServer(InetSocketAddress addr, SSLContext ss return HttpServer.create(addr, 0); } - @SuppressWarnings("java:S107") private static boolean wireBindings( HttpServer httpServer, List bindings, Map bodyMappers, - HandlerConfig handlerConfig, - ExceptionHandler exceptionHandler, - ResponseRenderer renderer) { + HandlerConfig handlerConfig) { boolean anyBindingAtRoot = false; for (SpecBinding binding : bindings) { String basePath = Optional.ofNullable(binding.spec().basePath()).orElse("/"); anyBindingAtRoot |= "/".equals(basePath); - wireBinding( - httpServer, basePath, binding, bodyMappers, handlerConfig, exceptionHandler, renderer); + wireBinding(httpServer, basePath, binding, bodyMappers, handlerConfig); } return anyBindingAtRoot; } - @SuppressWarnings("java:S107") private static void wireBinding( HttpServer httpServer, String basePath, SpecBinding binding, Map bodyMappers, - HandlerConfig handlerConfig, - ExceptionHandler exceptionHandler, - ResponseRenderer renderer) { + HandlerConfig handlerConfig) { Map operationsById = binding.spec().operations().stream() .collect(Collectors.toUnmodifiableMap(Operation::operationId, op -> op)); @@ -150,9 +144,10 @@ private static void wireBinding( binding.router(), binding.validator(), bodyMappers, - exceptionHandler, - renderer, - handlerConfig.afterHooks())); + handlerConfig.exceptionHandler(), + handlerConfig.renderer(), + handlerConfig.afterHooks(), + handlerConfig.bodyReader())); ctx.getFilters() .add( new SecurityFilter( @@ -166,15 +161,12 @@ private static void wireBinding( binding.handlers(), handlerConfig.interceptors(), handlerConfig.decorators(), - renderer)); + handlerConfig.renderer())); } private static void wireExtras( - HttpServer httpServer, - boolean anyBindingAtRoot, - Map extras, - ExceptionHandler exceptionHandler, - ResponseRenderer renderer) { + HttpServer httpServer, boolean anyBindingAtRoot, HandlerConfig handlerConfig) { + Map extras = handlerConfig.extras(); if (anyBindingAtRoot) { if (!extras.isEmpty()) { throw new IllegalStateException( @@ -182,9 +174,12 @@ private static void wireExtras( } return; } - ExtrasRouter extrasRouter = new ExtrasRouter(extras, renderer); + ExtrasRouter extrasRouter = + new ExtrasRouter(extras, handlerConfig.renderer(), handlerConfig.bodyReader()); HttpContext extrasCtx = httpServer.createContext("/", extrasRouter); - extrasCtx.getFilters().add(new ExceptionFilter(exceptionHandler, renderer)); + extrasCtx + .getFilters() + .add(new ExceptionFilter(handlerConfig.exceptionHandler(), handlerConfig.renderer())); } private void logStartup(long t0) { @@ -251,6 +246,10 @@ public static final class Builder { private final LinkedHashMap extras = new LinkedHashMap<>(); private final Map securityValidators = new LinkedHashMap<>(); private boolean externalAuth = false; + private long maxDecompressedRequestBytes = RequestBodyReader.DEFAULT_MAX_DECOMPRESSED_BYTES; + private long minCompressibleResponseBytes = ResponseRenderer.DEFAULT_MIN_COMPRESSIBLE_BYTES; + private final List requestCodings = new ArrayList<>(); + private final List responseCodings = new ArrayList<>(); private final List bindings = new ArrayList<>(); private Builder() {} @@ -395,6 +394,78 @@ public Builder https(Path certificateChainPem, Path privateKeyPem) { return this; } + /** + * Ceiling on the inflated size of a gzip request body, 10 MiB by default. A compressed payload + * can expand by orders of magnitude, so this bounds what a single request may allocate; + * exceeding it fails the request with 413. Bodies that arrive uncompressed are not affected. + */ + public Builder maxDecompressedRequestBytes(long maxDecompressedRequestBytes) { + if (maxDecompressedRequestBytes <= 0 || maxDecompressedRequestBytes > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "maxDecompressedRequestBytes must be between 1 and " + + Integer.MAX_VALUE + + ", got " + + maxDecompressedRequestBytes); + } + this.maxDecompressedRequestBytes = maxDecompressedRequestBytes; + return this; + } + + /** + * Smallest response body worth compressing, 1 KiB by default. Below this, the coding costs more + * than it saves. Set it to 0 to compress every compressible body, or high enough to exceed any + * response this server produces to stop compressing altogether — useful when a proxy in front + * already terminates compression. + */ + public Builder minCompressibleResponseBytes(long minCompressibleResponseBytes) { + if (minCompressibleResponseBytes < 0) { + throw new IllegalArgumentException( + "minCompressibleResponseBytes must be non-negative, got " + + minCompressibleResponseBytes); + } + this.minCompressibleResponseBytes = minCompressibleResponseBytes; + return this; + } + + /** + * Registers a content coding the server decodes on requests and applies to responses, alongside + * the built-in gzip. When a client weights several codings equally, the ones registered here + * win over gzip, in registration order; otherwise the client's weights decide. A decoded body + * is still held to {@link #maxDecompressedRequestBytes(long)}. + * + * @throws IllegalArgumentException if a token or alias is not a lower-case RFC 9110 token, or + * is one of the reserved {@code gzip}, {@code x-gzip}, {@code identity} or {@code *} + * @throws IllegalStateException if a token or alias is already registered in either direction + */ + public Builder contentCoding(ContentCoding coding) { + ContentCodings.requireRegistrable(coding, requestCodings); + ContentCodings.requireRegistrable(coding, responseCodings); + requestCodings.add(coding); + responseCodings.add(coding); + return this; + } + + /** + * Registers a coding the server only decodes on requests; responses never use it. Validated as + * for {@link #contentCoding(ContentCoding)}. + */ + public Builder requestContentCoding(ContentCoding coding) { + ContentCodings.requireRegistrable(coding, requestCodings); + requestCodings.add(coding); + return this; + } + + /** + * Registers a coding the server only applies to responses. A request coded with it is answered + * 415, which keeps a decoder you have no use for off the request path. Validated as for {@link + * #contentCoding(ContentCoding)}. + */ + public Builder responseContentCoding(ContentCoding coding) { + ContentCodings.requireRegistrable(coding, responseCodings); + responseCodings.add(coding); + return this; + } + /** * Sets the default drain timeout used by {@link OpenApiServer#close()}. {@code 0} (the default) * stops immediately; positive values wait up to that many seconds for in-flight exchanges to @@ -434,6 +505,7 @@ public OpenApiServer build() throws IOException { Map resolved = resolveBodyMappers(bodyMappers); ExceptionHandler effectiveExceptionHandler = exceptionHandler != null ? exceptionHandler : Handlers.defaultExceptionHandler(); + ContentCodings codings = ContentCodings.of(requestCodings, responseCodings); HandlerConfig handlerConfig = new HandlerConfig( interceptors, @@ -441,7 +513,9 @@ public OpenApiServer build() throws IOException { effectiveExceptionHandler, extras, externalAuth, - List.copyOf(afterHooks)); + List.copyOf(afterHooks), + new RequestBodyReader(maxDecompressedRequestBytes, codings.decoders()), + new ResponseRenderer(resolved, minCompressibleResponseBytes, codings.encoders())); int resolvedPort = resolvePort(); SSLContext sslContext = httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null; diff --git a/src/main/java/com/retailsvc/http/internal/AcceptEncodingHeader.java b/src/main/java/com/retailsvc/http/internal/AcceptEncodingHeader.java new file mode 100644 index 0000000..1f27c14 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/AcceptEncodingHeader.java @@ -0,0 +1,87 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.ContentCoding; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** Parses {@code Accept-Encoding} request header values (RFC 9110 §12.5.3). */ +public final class AcceptEncodingHeader { + + private static final double DEFAULT_WEIGHT = 1.0; + + private AcceptEncodingHeader() {} + + /** + * Chooses the coding the client weights highest among {@code candidates}, which arrive in server + * preference order, so the earlier candidate wins a tie. Empty means send the body uncoded — also + * the answer for an absent header, since not asking for a coding is not the same as accepting + * any. + * + *

A coding the client names outranks its {@code *} entry, so {@code gzip;q=0} refuses gzip + * even beside a positive wildcard. A weight of zero refuses, and a name listed twice takes its + * higher weight. + */ + public static Optional select(String header, List candidates) { + if (header == null || candidates.isEmpty()) { + return Optional.empty(); + } + Map weights = weights(header); + ContentCoding best = null; + double bestWeight = 0; + for (ContentCoding candidate : candidates) { + double weight = weightOf(candidate, weights); + if (weight > bestWeight) { + best = candidate; + bestWeight = weight; + } + } + return Optional.ofNullable(best); + } + + /** The weight the client gave each coding it listed, keeping the higher for a repeated one. */ + private static Map weights(String header) { + Map weights = new HashMap<>(); + for (String token : header.split(",")) { + int semi = token.indexOf(';'); + String coding = (semi < 0 ? token : token.substring(0, semi)).trim().toLowerCase(Locale.ROOT); + if (!coding.isEmpty()) { + weights.merge(coding, weight(token), Math::max); + } + } + return weights; + } + + /** A candidate's weight from its own name or an alias, and only failing both, the wildcard. */ + private static double weightOf(ContentCoding coding, Map weights) { + Double listed = weights.get(coding.token()); + for (String alias : coding.aliases()) { + Double aliased = weights.get(alias); + if (aliased != null && (listed == null || aliased > listed)) { + listed = aliased; + } + } + if (listed != null) { + return listed; + } + return weights.getOrDefault("*", 0.0); + } + + /** + * Reads the {@code q} weight from a token. An absent or unparsable weight reads as the default + * 1.0 — a malformed header should not silently disable compression. + */ + private static double weight(String token) { + String weight = ContentTypeHeader.parameter(token, "q").orElse(null); + if (weight == null) { + return DEFAULT_WEIGHT; + } + try { + return Double.parseDouble(weight); + } catch (NumberFormatException _) { + return DEFAULT_WEIGHT; + } + } +} diff --git a/src/main/java/com/retailsvc/http/internal/ContentCodings.java b/src/main/java/com/retailsvc/http/internal/ContentCodings.java new file mode 100644 index 0000000..20055bf --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ContentCodings.java @@ -0,0 +1,86 @@ +package com.retailsvc.http.internal; + +import static java.util.Objects.requireNonNull; + +import com.retailsvc.http.ContentCoding; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The content codings a server decodes and applies: the built-in gzip plus whatever the caller + * registered, split by direction. + * + * @param decoders request codings keyed by every token that names them, aliases included + * @param encoders response codings in server preference order, the built-in gzip last + */ +public record ContentCodings(Map decoders, List encoders) { + + private static final ContentCoding GZIP = new GzipCoding(); + private static final Set RESERVED = Set.of("gzip", "x-gzip", "identity", "*"); + private static final String TOKEN_SYMBOLS = "!#$%&'*+-.^_`|~"; + + public ContentCodings { + decoders = Map.copyOf(decoders); + encoders = List.copyOf(encoders); + } + + /** + * Builds the registry from codings already accepted by {@link #requireRegistrable}. Registered + * codings come before gzip, so a client that weights them equally gets the one the caller added. + */ + public static ContentCodings of( + List requestCodings, List responseCodings) { + Map decoders = new HashMap<>(); + for (ContentCoding coding : requestCodings) { + names(coding).forEach(name -> decoders.put(name, coding)); + } + names(GZIP).forEach(name -> decoders.put(name, GZIP)); + List encoders = new ArrayList<>(responseCodings); + encoders.add(GZIP); + return new ContentCodings(decoders, encoders); + } + + /** + * Rejects a coding that cannot be registered alongside {@code registered}: a token or alias that + * is not a lower-case RFC 9110 token, one that is reserved, or one already claimed. Tokens are + * written verbatim into response headers, so the token check also keeps those headers clean. + */ + public static void requireRegistrable(ContentCoding coding, List registered) { + requireNonNull(coding, "coding must not be null"); + Set names = names(coding); + names.forEach(ContentCodings::requireToken); + for (ContentCoding other : registered) { + for (String name : names(other)) { + if (names.contains(name)) { + throw new IllegalStateException("duplicate content coding '" + name + "'"); + } + } + } + } + + private static Set names(ContentCoding coding) { + Set names = new LinkedHashSet<>(); + names.add(coding.token()); + names.addAll(requireNonNull(coding.aliases(), "content coding aliases must not be null")); + return names; + } + + private static void requireToken(String token) { + requireNonNull(token, "content coding token must not be null"); + if (token.isEmpty() || !token.chars().allMatch(ContentCodings::isTokenChar)) { + throw new IllegalArgumentException( + "content coding '" + token + "' is not a lower-case RFC 9110 token"); + } + if (RESERVED.contains(token)) { + throw new IllegalArgumentException("content coding '" + token + "' is reserved"); + } + } + + private static boolean isTokenChar(int c) { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || TOKEN_SYMBOLS.indexOf(c) >= 0; + } +} diff --git a/src/main/java/com/retailsvc/http/internal/ContentEncodingHeader.java b/src/main/java/com/retailsvc/http/internal/ContentEncodingHeader.java new file mode 100644 index 0000000..c2ccfdb --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ContentEncodingHeader.java @@ -0,0 +1,51 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.ContentCoding; +import java.util.Locale; +import java.util.Map; + +/** Classifies a request {@code Content-Encoding} against the codings the server can decode. */ +public final class ContentEncodingHeader { + + private ContentEncodingHeader() {} + + /** How a request body is coded, as far as this server is concerned. */ + public sealed interface RequestCoding { + + /** Absent, blank, or the {@code identity} no-op: the body is already plain. */ + record Identity() implements RequestCoding {} + + /** A single coding this server can decode. */ + record Coded(ContentCoding coding) implements RequestCoding {} + + /** A coding this server cannot decode, or two stacked; the caller renders 415. */ + record Unsupported() implements RequestCoding {} + } + + /** + * Classifies the header value against {@code decoders}, keyed by lower-case token. {@code null}, + * blank and {@code identity} are all {@link RequestCoding.Identity}; a single known coding — + * optionally alongside {@code identity} — is {@link RequestCoding.Coded}. An unknown coding, or + * two stacked, is {@link RequestCoding.Unsupported}. + */ + public static RequestCoding parse(String header, Map decoders) { + if (header == null) { + return new RequestCoding.Identity(); + } + ContentCoding found = null; + for (String token : header.split(",")) { + String name = token.trim().toLowerCase(Locale.ROOT); + if (name.isEmpty() || "identity".equals(name)) { + continue; + } + if (found != null) { + return new RequestCoding.Unsupported(); + } + found = decoders.get(name); + if (found == null) { + return new RequestCoding.Unsupported(); + } + } + return found == null ? new RequestCoding.Identity() : new RequestCoding.Coded(found); + } +} diff --git a/src/main/java/com/retailsvc/http/internal/ExtrasRouter.java b/src/main/java/com/retailsvc/http/internal/ExtrasRouter.java index 62e06a4..fbe0d5c 100644 --- a/src/main/java/com/retailsvc/http/internal/ExtrasRouter.java +++ b/src/main/java/com/retailsvc/http/internal/ExtrasRouter.java @@ -21,9 +21,12 @@ private record Entry(PathPattern pattern, RequestHandler handler) {} private final Map exact; private final List wildcards; private final ResponseRenderer renderer; + private final RequestBodyReader bodyReader; - public ExtrasRouter(Map extras, ResponseRenderer renderer) { + public ExtrasRouter( + Map extras, ResponseRenderer renderer, RequestBodyReader bodyReader) { this.renderer = renderer; + this.bodyReader = bodyReader; Map exactBuilder = new LinkedHashMap<>(); List wildcardBuilder = new ArrayList<>(); for (Map.Entry e : extras.entrySet()) { @@ -55,18 +58,17 @@ public void handle(HttpExchange exchange) throws IOException { throw new NotFoundException(exchange.getRequestMethod() + " " + decoded); } - byte[] body = exchange.getRequestBody().readAllBytes(); + RequestBodyReader.Body body = bodyReader.read(exchange); HttpMethod method = HttpMethod.parse(exchange.getRequestMethod()); - var headers = exchange.getRequestHeaders(); Request request = new Request( - body, + body.bytes(), null, null, null, Map.of(), exchange.getRequestURI().getRawQuery(), - headers::getFirst, + body.headerLookup(), Map.of(), method); Response response = hit.handle(request); diff --git a/src/main/java/com/retailsvc/http/internal/GzipCoding.java b/src/main/java/com/retailsvc/http/internal/GzipCoding.java new file mode 100644 index 0000000..1eec369 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/GzipCoding.java @@ -0,0 +1,36 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.ContentCoding; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Set; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** The built-in {@code gzip} coding, which every server offers. */ +final class GzipCoding implements ContentCoding { + + private static final Set ALIASES = Set.of("x-gzip"); + private static final int BUFFER_SIZE = 8192; + + @Override + public String token() { + return "gzip"; + } + + @Override + public Set aliases() { + return ALIASES; + } + + @Override + public InputStream decode(InputStream coded) throws IOException { + return new GZIPInputStream(coded, BUFFER_SIZE); + } + + @Override + public OutputStream encode(OutputStream sink) throws IOException { + return new GZIPOutputStream(sink); + } +} diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java index a688806..9edaf6d 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java @@ -1,6 +1,7 @@ package com.retailsvc.http.internal; import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_ENTITY_TOO_LARGE; import com.retailsvc.http.BadRequestException; import com.retailsvc.http.validate.ValidationError; @@ -79,27 +80,18 @@ private static int depth(String pointer) { } private static final Map TITLES = - Map.of( - HTTP_BAD_REQUEST, - BAD_REQUEST, - 401, - "Unauthorized", - 403, - "Forbidden", - 404, - "Not Found", - 405, - "Method Not Allowed", - 409, - "Conflict", - 410, - "Gone", - 412, - "Precondition Failed", - 415, - "Unsupported Media Type", - 422, - "Unprocessable Content"); + Map.ofEntries( + Map.entry(HTTP_BAD_REQUEST, BAD_REQUEST), + Map.entry(401, "Unauthorized"), + Map.entry(403, "Forbidden"), + Map.entry(404, "Not Found"), + Map.entry(405, "Method Not Allowed"), + Map.entry(409, "Conflict"), + Map.entry(410, "Gone"), + Map.entry(412, "Precondition Failed"), + Map.entry(HTTP_ENTITY_TOO_LARGE, "Content Too Large"), + Map.entry(415, "Unsupported Media Type"), + Map.entry(422, "Unprocessable Content")); private static String titleFor(int status) { return TITLES.getOrDefault(status, BAD_REQUEST); diff --git a/src/main/java/com/retailsvc/http/internal/RequestBodyReader.java b/src/main/java/com/retailsvc/http/internal/RequestBodyReader.java new file mode 100644 index 0000000..d3deed9 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/RequestBodyReader.java @@ -0,0 +1,111 @@ +package com.retailsvc.http.internal; + +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_ENTITY_TOO_LARGE; +import static java.net.HttpURLConnection.HTTP_UNSUPPORTED_TYPE; + +import com.retailsvc.http.BadRequestException; +import com.retailsvc.http.ContentCoding; +import com.retailsvc.http.internal.ContentEncodingHeader.RequestCoding.Coded; +import com.retailsvc.http.internal.ContentEncodingHeader.RequestCoding.Identity; +import com.retailsvc.http.internal.ContentEncodingHeader.RequestCoding.Unsupported; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.function.UnaryOperator; + +/** + * Reads the raw request body, transparently decoding a registered {@code Content-Encoding} under a + * hard cap on the decoded size. Immutable and shared across requests. + */ +public final class RequestBodyReader { + + /** Default ceiling on the decoded size of a coded request body: 10 MiB. */ + public static final long DEFAULT_MAX_DECOMPRESSED_BYTES = 10L * 1024 * 1024; + + private static final String CONTENT_ENCODING = "Content-Encoding"; + private static final String CONTENT_LENGTH = "Content-Length"; + + private final long maxDecompressedBytes; + private final int readLimit; + private final Map decoders; + + public RequestBodyReader(long maxDecompressedBytes, Map decoders) { + if (maxDecompressedBytes <= 0) { + throw new IllegalArgumentException( + "maxDecompressedBytes must be positive, got " + maxDecompressedBytes); + } + this.maxDecompressedBytes = maxDecompressedBytes; + this.readLimit = (int) Math.min(maxDecompressedBytes, Integer.MAX_VALUE - 1L) + 1; + this.decoders = Map.copyOf(decoders); + } + + /** + * Reads and decodes the request body. + * + * @throws BadRequestException 415 when the coding is not one this server decodes, 413 when the + * decoded body exceeds the cap, 400 when the coded body is malformed or truncated + */ + public Body read(HttpExchange exchange) throws IOException { + Headers headers = exchange.getRequestHeaders(); + String header = headers.getFirst(CONTENT_ENCODING); + byte[] raw = exchange.getRequestBody().readAllBytes(); + return switch (ContentEncodingHeader.parse(header, decoders)) { + case Identity _ -> new Body(raw, headers::getFirst); + case Coded(ContentCoding coding) -> decoded(decode(coding, raw), headers); + case Unsupported _ -> + throw new BadRequestException( + HTTP_UNSUPPORTED_TYPE, "unsupported Content-Encoding: " + header); + }; + } + + /** + * Decodes a complete coded body. The body is buffered before this runs, so the coding only ever + * reads memory: any read failure is the body's fault, and an empty body stays empty rather than + * failing the way a stream with no header would. + */ + private byte[] decode(ContentCoding coding, byte[] raw) { + if (raw.length == 0) { + return raw; + } + try (InputStream in = coding.decode(new ByteArrayInputStream(raw))) { + byte[] decoded = in.readNBytes(readLimit); + if (decoded.length > maxDecompressedBytes) { + throw new BadRequestException( + HTTP_ENTITY_TOO_LARGE, + "decompressed request body exceeds " + maxDecompressedBytes + " bytes"); + } + return decoded; + } catch (IOException e) { + throw new BadRequestException( + HTTP_BAD_REQUEST, "malformed " + coding.token() + " request body", e); + } + } + + /** + * Presents the decoded body as if it had arrived uncoded: the coding is gone, so reporting it + * alongside the decoded bytes would misdescribe them, and the stored length measures the coded + * payload rather than what the handler can read. + */ + private static Body decoded(byte[] bytes, Headers headers) { + String decodedLength = Integer.toString(bytes.length); + return new Body( + bytes, + name -> { + if (CONTENT_ENCODING.equalsIgnoreCase(name)) { + return null; + } + if (CONTENT_LENGTH.equalsIgnoreCase(name)) { + return decodedLength; + } + return headers.getFirst(name); + }); + } + + /** A decoded request body and the header view a handler should see alongside it. */ + @SuppressWarnings("java:S6218") + public record Body(byte[] bytes, UnaryOperator headerLookup) {} +} diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 3365d25..8d773d5 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -40,6 +40,7 @@ public final class RequestPreparationFilter extends Filter { private final ExceptionHandler exceptionHandler; private final ResponseRenderer renderer; private final List afterHooks; + private final RequestBodyReader bodyReader; @SuppressWarnings("java:S107") public RequestPreparationFilter( @@ -49,7 +50,8 @@ public RequestPreparationFilter( Map bodyMappers, ExceptionHandler exceptionHandler, ResponseRenderer renderer, - List afterHooks) { + List afterHooks, + RequestBodyReader bodyReader) { this.spec = spec; this.router = router; this.validator = validator; @@ -57,6 +59,7 @@ public RequestPreparationFilter( this.exceptionHandler = exceptionHandler; this.renderer = renderer; this.afterHooks = List.copyOf(afterHooks); + this.bodyReader = bodyReader; } @Override @@ -94,7 +97,8 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { } private Request buildRequest(HttpExchange exchange) throws IOException { - byte[] body = exchange.getRequestBody().readAllBytes(); + RequestBodyReader.Body decoded = bodyReader.read(exchange); + byte[] body = decoded.bytes(); HttpMethod method = HttpMethod.parse(exchange.getRequestMethod()); String path = stripBasePath(exchange.getRequestURI().getPath()); @@ -113,7 +117,6 @@ private Request buildRequest(HttpExchange exchange) throws IOException { validateParameters(exchange, op, match.pathParameters()); ParsedBody parsedBody = validateAndParseBody(exchange, op, body); - var headers = exchange.getRequestHeaders(); return new Request( body, parsedBody.value(), @@ -121,7 +124,7 @@ private Request buildRequest(HttpExchange exchange) throws IOException { op.operationId(), match.pathParameters(), exchange.getRequestURI().getRawQuery(), - headers::getFirst, + decoded.headerLookup(), Map.of(), method); } diff --git a/src/main/java/com/retailsvc/http/internal/ResponseCompression.java b/src/main/java/com/retailsvc/http/internal/ResponseCompression.java new file mode 100644 index 0000000..2b2d018 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ResponseCompression.java @@ -0,0 +1,53 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.ContentCoding; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Set; + +/** Which responses are worth coding, and coding a whole body at once. */ +public final class ResponseCompression { + + private static final Set COMPRESSIBLE_TYPES = + Set.of( + "application/json", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/javascript", + "application/x-ndjson"); + + private ResponseCompression() {} + + /** + * Whether a response of this content type is worth compressing. Already-compressed payloads gain + * nothing, and {@code text/event-stream} must stay unbuffered so each event reaches the client as + * it is written. + * + *

An absent content type is never compressible. It cannot be resolved through {@link + * ContentTypeHeader#mediaType} here, because that reads {@code null} as {@code application/json}. + */ + public static boolean isCompressible(String contentType) { + if (contentType == null) { + return false; + } + String mediaType = ContentTypeHeader.mediaType(contentType); + if (mediaType.startsWith("text/")) { + return !"text/event-stream".equals(mediaType); + } + if (mediaType.endsWith("+json") || mediaType.endsWith("+xml") || mediaType.endsWith("+yaml")) { + return true; + } + return COMPRESSIBLE_TYPES.contains(mediaType); + } + + /** Codes {@code body} completely with {@code coding}. */ + public static byte[] encode(ContentCoding coding, byte[] body) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (OutputStream coded = coding.encode(out)) { + coded.write(body); + } + return out.toByteArray(); + } +} diff --git a/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java index 204935f..d75fdc1 100644 --- a/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java @@ -1,25 +1,47 @@ package com.retailsvc.http.internal; +import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_PARTIAL; +import static java.net.HttpURLConnection.HTTP_RESET; + +import com.retailsvc.http.ContentCoding; import com.retailsvc.http.Response; import com.retailsvc.http.TypeMapper; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.io.IOException; import java.io.OutputStream; +import java.util.List; import java.util.Locale; import java.util.Map; /** Writes a {@link Response} to an {@link HttpExchange}. */ public final class ResponseRenderer { + /** Default smallest body worth compressing: 1 KiB. */ + public static final long DEFAULT_MIN_COMPRESSIBLE_BYTES = 1024; + private static final String CONTENT_TYPE = "Content-Type"; + private static final String CONTENT_ENCODING = "Content-Encoding"; + private static final String CONTENT_LENGTH = "Content-Length"; + private static final String VARY = "Vary"; + private static final String ACCEPT_ENCODING = "Accept-Encoding"; + private static final long UNKNOWN_LENGTH = -1; + private static final long CHUNKED = 0; private static final String DEFAULT_JSON = "application/json"; private static final String OCTET_STREAM = "application/octet-stream"; private final Map mappers; + private final long minCompressibleBytes; + private final List encoders; - public ResponseRenderer(Map mappers) { + public ResponseRenderer( + Map mappers, long minCompressibleBytes, List encoders) { this.mappers = Map.copyOf(mappers); + this.minCompressibleBytes = minCompressibleBytes; + this.encoders = List.copyOf(encoders); } public void render(HttpExchange exchange, Response response) throws IOException { @@ -31,7 +53,7 @@ public void render(HttpExchange exchange, Response response) throws IOException int status = response.status(); if (body == null) { - exchange.sendResponseHeaders(status, -1); + renderEmpty(exchange, headers, status, response.contentType()); } else if (body instanceof BodyWriter writer) { renderStream(exchange, headers, status, response.contentType(), writer); } else { @@ -40,40 +62,139 @@ public void render(HttpExchange exchange, Response response) throws IOException } } - private static void renderStream( + /** + * Writes a bodiless response. Nothing can be coded here, but the response still has to say how a + * body would have been coded: a length declared for a body the client will fetch separately would + * describe the uncoded form, which is not what a coded {@code GET} would return. + */ + private void renderEmpty(HttpExchange exchange, Headers headers, int status, String contentType) + throws IOException { + defaultContentType(headers, contentType); + long declared = declaredLength(headers); + if (selectCoding(exchange, headers, status, contentType, declared) != null && declared >= 0) { + headers.remove(CONTENT_LENGTH); + } + exchange.sendResponseHeaders(status, UNKNOWN_LENGTH); + } + + private void renderStream( HttpExchange exchange, Headers headers, int status, String contentType, BodyWriter writer) throws IOException { + defaultContentType(headers, contentType); + long declared = writer instanceof BodyWriter.Sized sized ? sized.length() : UNKNOWN_LENGTH; + ContentCoding coding = selectCoding(exchange, headers, status, contentType, declared); + if (coding != null) { + headers.set(CONTENT_ENCODING, coding.token()); + // The coded body goes out chunked, and the JDK leaves a handler-set length in place there. + headers.remove(CONTENT_LENGTH); + } + exchange.sendResponseHeaders(status, coding != null ? CHUNKED : Math.max(declared, CHUNKED)); + try (OutputStream out = + coding != null ? coding.encode(exchange.getResponseBody()) : exchange.getResponseBody()) { + writer.writeTo(out); + } + } + + /** Adds the response's own content type unless the handler already set one. */ + private static void defaultContentType(Headers headers, String contentType) { if (contentType != null && !headers.containsKey(CONTENT_TYPE)) { headers.add(CONTENT_TYPE, contentType); } - long length = writer instanceof BodyWriter.Sized sized ? sized.length() : 0; - exchange.sendResponseHeaders(status, length); - try (OutputStream out = exchange.getResponseBody()) { - writer.writeTo(out); + } + + /** + * The coding to apply to a body of {@code length} bytes, or {@code null} to send it uncoded, + * marking the response as varying by {@code Accept-Encoding} whenever it could have been coded. A + * negative length means unknown, which counts as over the threshold: measuring a stream to find + * out would defeat streaming it. + */ + private ContentCoding selectCoding( + HttpExchange exchange, Headers headers, int status, String contentType, long length) { + if (headers.containsKey(CONTENT_ENCODING) + || !ResponseCompression.isCompressible(contentType) + || !bodyAllowed(status)) { + return null; + } + addVary(headers); + if (length >= 0 && length < minCompressibleBytes) { + return null; + } + String accepted = exchange.getRequestHeaders().getFirst(ACCEPT_ENCODING); + return AcceptEncodingHeader.select(accepted, encoders).orElse(null); + } + + /** The length a handler declared for a body it did not write, or -1 when absent or unreadable. */ + private static long declaredLength(Headers headers) { + String declared = headers.getFirst(CONTENT_LENGTH); + if (declared == null) { + return UNKNOWN_LENGTH; + } + try { + return Long.parseLong(declared.trim()); + } catch (NumberFormatException _) { + return UNKNOWN_LENGTH; } } private void renderBytes( HttpExchange exchange, Headers headers, int status, String contentType, Object body) throws IOException { - byte[] bytes; - String effectiveContentType; - if (body instanceof byte[] raw) { - bytes = raw; - effectiveContentType = contentType != null ? contentType : OCTET_STREAM; - } else { - effectiveContentType = contentType != null ? contentType : DEFAULT_JSON; - bytes = serialize(body, effectiveContentType); + String fallback = body instanceof byte[] ? OCTET_STREAM : DEFAULT_JSON; + String effectiveContentType = contentType != null ? contentType : fallback; + byte[] bytes = body instanceof byte[] raw ? raw : serialize(body, effectiveContentType); + defaultContentType(headers, effectiveContentType); + byte[] payload = maybeCompress(exchange, headers, status, effectiveContentType, bytes); + exchange.sendResponseHeaders(status, payload.length == 0 ? UNKNOWN_LENGTH : payload.length); + if (payload.length > 0) { + try (OutputStream out = exchange.getResponseBody()) { + out.write(payload); + } } - if (!headers.containsKey(CONTENT_TYPE)) { - headers.add(CONTENT_TYPE, effectiveContentType); + } + + /** Codes the body when it is worth it, leaving a payload the coding fails to shrink uncoded. */ + private byte[] maybeCompress( + HttpExchange exchange, Headers headers, int status, String contentType, byte[] bytes) + throws IOException { + ContentCoding coding = selectCoding(exchange, headers, status, contentType, bytes.length); + if (coding == null) { + return bytes; } - exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); - if (bytes.length > 0) { - try (OutputStream out = exchange.getResponseBody()) { - out.write(bytes); + byte[] coded = ResponseCompression.encode(coding, bytes); + if (coded.length >= bytes.length) { + return bytes; + } + headers.set(CONTENT_ENCODING, coding.token()); + return coded; + } + + /** Statuses that carry no content cannot carry a content coding either. */ + private static boolean bodyAllowed(int status) { + return status >= HTTP_OK + && status != HTTP_NO_CONTENT + && status != HTTP_RESET + && status != HTTP_PARTIAL + && status != HTTP_NOT_MODIFIED; + } + + /** + * Marks the response as varying by {@code Accept-Encoding} so shared caches keep the coded and + * uncoded forms apart. Announced whenever the body could have been coded, not only when it was, + * and merged into one field line so a client reading a single value sees the whole list. + */ + private static void addVary(Headers headers) { + String existing = headers.getFirst(VARY); + if (existing == null) { + headers.set(VARY, ACCEPT_ENCODING); + return; + } + for (String field : existing.split(",")) { + String trimmed = field.trim(); + if ("*".equals(trimmed) || ACCEPT_ENCODING.equalsIgnoreCase(trimmed)) { + return; } } + headers.set(VARY, existing + ", " + ACCEPT_ENCODING); } private byte[] serialize(Object body, String contentType) { diff --git a/src/test/java/com/retailsvc/http/ContentCodingIT.java b/src/test/java/com/retailsvc/http/ContentCodingIT.java new file mode 100644 index 0000000..ecf5d48 --- /dev/null +++ b/src/test/java/com/retailsvc/http/ContentCodingIT.java @@ -0,0 +1,229 @@ +package com.retailsvc.http; + +import static com.retailsvc.http.support.TestCodings.deflate; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_ENTITY_TOO_LARGE; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNSUPPORTED_TYPE; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.start.TextEchoHandler; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; +import java.util.Optional; +import java.util.function.UnaryOperator; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; +import org.junit.jupiter.api.Test; + +/** + * End-to-end coverage of a caller-registered content coding. The coding is {@code deflate} from + * java.util.zip, so the extension point is proved without any compression dependency. + */ +class ContentCodingIT extends ServerBaseTest { + + private static final String CONTENT_ENCODING = "Content-Encoding"; + private static final String ACCEPT_ENCODING = "Accept-Encoding"; + private static final String PAYLOAD = "compress me please ".repeat(200); + + // -- requests -- + + @Test + void deflatedRequestBodyIsDecoded() throws Exception { + try (var s = echoServer(b -> b.contentCoding(deflate())); + var client = httpClient()) { + var request = + textEcho(s, deflated("hello deflate".getBytes(UTF_8))) + .header(CONTENT_ENCODING, "deflate") + .build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_OK); + assertThat(response.body()).isEqualTo("hello deflate"); + } + } + + @Test + void deflateBombIsStoppedByTheServersCap() throws Exception { + try (var s = echoServer(b -> b.contentCoding(deflate()).maxDecompressedRequestBytes(1024)); + var client = httpClient()) { + var request = + textEcho(s, deflated(new byte[8192])).header(CONTENT_ENCODING, "deflate").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_ENTITY_TOO_LARGE); + } + } + + @Test + void malformedDeflateBodyReturns400() throws Exception { + try (var s = echoServer(b -> b.contentCoding(deflate())); + var client = httpClient()) { + var request = + textEcho(s, "not deflate at all".getBytes(UTF_8)) + .header(CONTENT_ENCODING, "deflate") + .build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_BAD_REQUEST); + assertThat(response.body()).contains("malformed deflate request body"); + } + } + + // -- responses -- + + @Test + void responseIsCodedWithTheRegisteredCoding() throws Exception { + try (var s = echoServer(b -> b.contentCoding(deflate())); + var client = httpClient()) { + var request = textEcho(s, PAYLOAD.getBytes(UTF_8)).header(ACCEPT_ENCODING, "deflate").build(); + + HttpResponse response = client.send(request, BodyHandlers.ofByteArray()); + + assertThat(response.headers().firstValue(CONTENT_ENCODING)).contains("deflate"); + assertThat(response.headers().firstValue("Vary")) + .hasValueSatisfying(vary -> assertThat(vary).contains(ACCEPT_ENCODING)); + assertThat(new String(inflate(response.body()), UTF_8)).isEqualTo(PAYLOAD); + } + } + + @Test + void registeredCodingWinsATieWithGzip() throws Exception { + assertThat(codingChosenFor("gzip, deflate")).contains("deflate"); + } + + @Test + void clientWeightStillDecides() throws Exception { + assertThat(codingChosenFor("deflate;q=0.1, gzip;q=0.9")).contains("gzip"); + } + + @Test + void streamedResponseUsesTheRegisteredCoding() throws Exception { + try (var s = + echoServer( + b -> + b.contentCoding(deflate()) + .extraRoute("/openapi.yaml", Handlers.resourceHandler("/openapi.yaml"))); + var client = httpClient()) { + var request = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/openapi.yaml".formatted(s.listenPort()))) + .header(ACCEPT_ENCODING, "deflate") + .GET() + .build(); + + HttpResponse response = client.send(request, BodyHandlers.ofByteArray()); + + assertThat(response.headers().firstValue(CONTENT_ENCODING)).contains("deflate"); + assertThat(response.headers().firstValue("Content-Length")).isEmpty(); + assertThat(inflate(response.body())).isEqualTo(classpathBytes()); + } + } + + // -- one direction only -- + + @Test + void requestOnlyCodingDecodesButNeverCodesAResponse() throws Exception { + try (var s = echoServer(b -> b.requestContentCoding(deflate())); + var client = httpClient()) { + var request = + textEcho(s, deflated(PAYLOAD.getBytes(UTF_8))) + .header(CONTENT_ENCODING, "deflate") + .header(ACCEPT_ENCODING, "deflate") + .build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_OK); + assertThat(response.headers().firstValue(CONTENT_ENCODING)).isEmpty(); + assertThat(response.body()).isEqualTo(PAYLOAD); + } + } + + @Test + void responseOnlyCodingCodesButIsRefusedOnRequests() throws Exception { + try (var s = echoServer(b -> b.responseContentCoding(deflate())); + var client = httpClient()) { + var coded = textEcho(s, PAYLOAD.getBytes(UTF_8)).header(ACCEPT_ENCODING, "deflate").build(); + var refused = + textEcho(s, deflated(PAYLOAD.getBytes(UTF_8))) + .header(CONTENT_ENCODING, "deflate") + .build(); + + var codedResponse = client.send(coded, BodyHandlers.ofByteArray()); + var refusedResponse = client.send(refused, BodyHandlers.ofString()); + + assertThat(codedResponse.headers().firstValue(CONTENT_ENCODING)).contains("deflate"); + assertThat(refusedResponse.statusCode()).isEqualTo(HTTP_UNSUPPORTED_TYPE); + } + } + + // -- fixtures -- + + private Optional codingChosenFor(String acceptEncoding) throws Exception { + try (var s = echoServer(b -> b.contentCoding(deflate())); + var client = httpClient()) { + var request = + textEcho(s, PAYLOAD.getBytes(UTF_8)).header(ACCEPT_ENCODING, acceptEncoding).build(); + return client + .send(request, BodyHandlers.ofByteArray()) + .headers() + .firstValue(CONTENT_ENCODING); + } + } + + private OpenApiServer echoServer(UnaryOperator customise) { + try { + server = + customise + .apply( + newBuilder() + .spec(spec) + .handlers(stubAllHandlers(Map.of("text-echo", new TextEchoHandler()))) + .port(0)) + .build(); + return server; + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private HttpRequest.Builder textEcho(OpenApiServer server, byte[] body) { + return HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/text-echo".formatted(server.listenPort()))) + .header("Content-Type", "text/plain") + .POST(BodyPublishers.ofByteArray(body)); + } + + private static byte[] classpathBytes() throws IOException { + try (InputStream in = ContentCodingIT.class.getResourceAsStream("/openapi.yaml")) { + return in.readAllBytes(); + } + } + + private static byte[] deflated(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (DeflaterOutputStream deflater = new DeflaterOutputStream(out)) { + deflater.write(data); + } + return out.toByteArray(); + } + + private static byte[] inflate(byte[] data) throws IOException { + try (InflaterInputStream in = new InflaterInputStream(new ByteArrayInputStream(data))) { + return in.readAllBytes(); + } + } +} diff --git a/src/test/java/com/retailsvc/http/GzipIT.java b/src/test/java/com/retailsvc/http/GzipIT.java new file mode 100644 index 0000000..fce8373 --- /dev/null +++ b/src/test/java/com/retailsvc/http/GzipIT.java @@ -0,0 +1,307 @@ +package com.retailsvc.http; + +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_ENTITY_TOO_LARGE; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNSUPPORTED_TYPE; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.start.TextEchoHandler; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; +import java.util.function.UnaryOperator; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.Test; + +/** End-to-end coverage of gzip request decoding and response coding over a real socket. */ +class GzipIT extends ServerBaseTest { + + private static final String TEXT_PLAIN = "text/plain"; + private static final String CONTENT_ENCODING = "Content-Encoding"; + private static final String ACCEPT_ENCODING = "Accept-Encoding"; + private static final String CONTENT_LENGTH = "Content-Length"; + + // -- request decoding -- + + @Test + void gzippedRequestBodyIsDecompressed() throws Exception { + try (var s = echoServer(); + var client = httpClient()) { + var request = + textEcho(s, gzip("hello gzip".getBytes(UTF_8))).header(CONTENT_ENCODING, "gzip").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_OK); + assertThat(response.body()).isEqualTo("hello gzip"); + } + } + + @Test + void identityCodedRequestBodyIsUnaffected() throws Exception { + try (var s = echoServer(); + var client = httpClient()) { + var request = + textEcho(s, "plain".getBytes(UTF_8)).header(CONTENT_ENCODING, "identity").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_OK); + assertThat(response.body()).isEqualTo("plain"); + } + } + + @Test + void handlerDoesNotSeeContentEncodingHeader() throws Exception { + RequestHandler reportsEncoding = + req -> Response.text(HTTP_OK, req.header(CONTENT_ENCODING).orElse("absent")); + try (var s = serverWith(Map.of("text-echo", reportsEncoding)); + var client = httpClient()) { + var request = + textEcho(s, gzip("body".getBytes(UTF_8))).header(CONTENT_ENCODING, "gzip").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.body()).isEqualTo("absent"); + } + } + + @Test + void unsupportedRequestEncodingReturns415() throws Exception { + try (var s = echoServer(); + var client = httpClient()) { + var request = textEcho(s, "body".getBytes(UTF_8)).header(CONTENT_ENCODING, "br").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_UNSUPPORTED_TYPE); + assertThat(response.headers().firstValue("Content-Type")) + .contains("application/problem+json"); + assertThat(response.body()).contains("Unsupported Media Type"); + } + } + + @Test + void malformedGzipRequestReturns400() throws Exception { + try (var s = echoServer(); + var client = httpClient()) { + var request = + textEcho(s, "not gzip at all".getBytes(UTF_8)).header(CONTENT_ENCODING, "gzip").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_BAD_REQUEST); + assertThat(response.body()).contains("malformed gzip request body"); + } + } + + @Test + void oversizedGzipRequestReturns413() throws Exception { + try (var s = echoServer(builder -> builder.maxDecompressedRequestBytes(1024)); + var client = httpClient()) { + var request = textEcho(s, gzip(new byte[8192])).header(CONTENT_ENCODING, "gzip").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_ENTITY_TOO_LARGE); + assertThat(response.body()).contains("Content Too Large"); + } + } + + // -- response coding -- + + @Test + void largeResponseIsGzippedWhenClientAcceptsGzip() throws Exception { + String payload = "compress me please ".repeat(200); + try (var s = echoServer(); + var client = httpClient()) { + var request = textEcho(s, payload.getBytes(UTF_8)).header(ACCEPT_ENCODING, "gzip").build(); + + HttpResponse response = client.send(request, BodyHandlers.ofByteArray()); + + assertThat(response.headers().firstValue(CONTENT_ENCODING)).contains("gzip"); + assertThat(response.headers().firstValue("Vary")) + .hasValueSatisfying(vary -> assertThat(vary).contains(ACCEPT_ENCODING)); + assertThat(new String(gunzip(response.body()), UTF_8)).isEqualTo(payload); + assertThat(response.body()).hasSizeLessThan(payload.length()); + } + } + + @Test + void responseIsNotGzippedWithoutAcceptEncoding() throws Exception { + String payload = "compress me please ".repeat(200); + try (var s = echoServer(); + var client = httpClient()) { + var request = textEcho(s, payload.getBytes(UTF_8)).build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.headers().firstValue(CONTENT_ENCODING)).isEmpty(); + assertThat(response.body()).isEqualTo(payload); + } + } + + @Test + void smallResponseIsNotGzipped() throws Exception { + try (var s = echoServer(); + var client = httpClient()) { + var request = textEcho(s, "tiny".getBytes(UTF_8)).header(ACCEPT_ENCODING, "gzip").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.headers().firstValue(CONTENT_ENCODING)).isEmpty(); + assertThat(response.body()).isEqualTo("tiny"); + } + } + + @Test + void gzipRefusedByQValueIsNotApplied() throws Exception { + String payload = "compress me please ".repeat(200); + try (var s = echoServer(); + var client = httpClient()) { + var request = + textEcho(s, payload.getBytes(UTF_8)).header(ACCEPT_ENCODING, "gzip;q=0").build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.headers().firstValue(CONTENT_ENCODING)).isEmpty(); + assertThat(response.body()).isEqualTo(payload); + } + } + + // -- streamed extra routes -- + + @Test + void streamedSpecResourceIsGzippedAndChunked() throws Exception { + try (var s = specServer(); + var client = httpClient()) { + var request = specRequest(s).header(ACCEPT_ENCODING, "gzip").GET().build(); + + HttpResponse response = client.send(request, BodyHandlers.ofByteArray()); + + assertThat(response.statusCode()).isEqualTo(HTTP_OK); + assertThat(response.headers().firstValue(CONTENT_ENCODING)).contains("gzip"); + assertThat(response.headers().firstValue(CONTENT_LENGTH)).isEmpty(); + assertThat(gunzip(response.body())).isEqualTo(classpathBytes()); + } + } + + @Test + void streamedSpecResourceIsPlainWithoutAcceptEncoding() throws Exception { + try (var s = specServer(); + var client = httpClient()) { + var request = specRequest(s).GET().build(); + + HttpResponse response = client.send(request, BodyHandlers.ofByteArray()); + + assertThat(response.headers().firstValue(CONTENT_ENCODING)).isEmpty(); + assertThat(response.body()).isEqualTo(classpathBytes()); + } + } + + @Test + void headOmitsContentLengthWhenGetWouldBeCompressed() throws Exception { + try (var s = specServer(); + var client = httpClient()) { + var request = + specRequest(s) + .header(ACCEPT_ENCODING, "gzip") + .method("HEAD", BodyPublishers.noBody()) + .build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(HTTP_OK); + assertThat(response.headers().firstValue(CONTENT_LENGTH)).isEmpty(); + assertThat(response.headers().firstValue("Content-Type")).contains("application/yaml"); + } + } + + @Test + void headKeepsContentLengthWithoutAcceptEncoding() throws Exception { + try (var s = specServer(); + var client = httpClient()) { + var request = specRequest(s).method("HEAD", BodyPublishers.noBody()).build(); + + var response = client.send(request, BodyHandlers.ofString()); + + assertThat(response.headers().firstValue(CONTENT_LENGTH)) + .contains(String.valueOf(classpathBytes().length)); + } + } + + // -- fixtures -- + + private OpenApiServer echoServer() { + return echoServer(builder -> builder); + } + + private OpenApiServer echoServer(UnaryOperator customise) { + return serverWith(Map.of("text-echo", new TextEchoHandler()), customise); + } + + private OpenApiServer serverWith(Map handlers) { + return serverWith(handlers, builder -> builder); + } + + private OpenApiServer serverWith( + Map handlers, UnaryOperator customise) { + try { + server = + customise + .apply(newBuilder().spec(spec).handlers(stubAllHandlers(handlers)).port(0)) + .build(); + return server; + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private OpenApiServer specServer() { + return serverWith( + Map.of(), + builder -> builder.extraRoute("/openapi.yaml", Handlers.resourceHandler("/openapi.yaml"))); + } + + private HttpRequest.Builder textEcho(OpenApiServer server, byte[] body) { + return HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/text-echo".formatted(server.listenPort()))) + .header("Content-Type", TEXT_PLAIN) + .POST(BodyPublishers.ofByteArray(body)); + } + + private HttpRequest.Builder specRequest(OpenApiServer server) { + return HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/openapi.yaml".formatted(server.listenPort()))); + } + + private static byte[] classpathBytes() throws IOException { + try (InputStream in = GzipIT.class.getResourceAsStream("/openapi.yaml")) { + return in.readAllBytes(); + } + } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write(data); + } + return out.toByteArray(); + } + + private static byte[] gunzip(byte[] data) throws IOException { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(data))) { + return in.readAllBytes(); + } + } +} diff --git a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java index f0f1bc2..ce755e6 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java @@ -1,6 +1,9 @@ package com.retailsvc.http; +import static com.retailsvc.http.support.TestCodings.deflate; +import static com.retailsvc.http.support.TestCodings.named; import static java.util.Collections.emptyMap; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -49,6 +52,78 @@ void rejectsExtraPathEqualToSpecBasePathAtBuildTime() { .hasMessageContaining("/api"); } + @Test + void rejectsNonPositiveMaxDecompressedRequestBytes() { + OpenApiServer.Builder b = OpenApiServer.builder(); + + assertThatThrownBy(() -> b.maxDecompressedRequestBytes(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0"); + assertThatThrownBy(() -> b.maxDecompressedRequestBytes(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("-1"); + } + + @Test + void rejectsOversizedMaxDecompressedRequestBytes() { + OpenApiServer.Builder b = OpenApiServer.builder(); + + assertThatThrownBy(() -> b.maxDecompressedRequestBytes(Integer.MAX_VALUE + 1L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsNegativeMinCompressibleResponseBytes() { + OpenApiServer.Builder b = OpenApiServer.builder(); + + assertThatThrownBy(() -> b.minCompressibleResponseBytes(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("-1"); + } + + @Test + void acceptsContentCodingLimits() { + OpenApiServer.Builder b = OpenApiServer.builder(); + + assertThat(b.maxDecompressedRequestBytes(4096).minCompressibleResponseBytes(0)).isSameAs(b); + } + + @Test + void contentCodingRejectsTheBuiltInGzip() { + OpenApiServer.Builder b = OpenApiServer.builder(); + ContentCoding gzip = named("gzip"); + + assertThatThrownBy(() -> b.contentCoding(gzip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("gzip"); + } + + @Test + void contentCodingRejectsATokenRegisteredTwice() { + OpenApiServer.Builder b = OpenApiServer.builder().contentCoding(deflate()); + ContentCoding again = deflate(); + + assertThatThrownBy(() -> b.contentCoding(again)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("deflate"); + } + + @Test + void oneCodingMayBeRegisteredOncePerDirection() { + ContentCoding deflate = deflate(); + OpenApiServer.Builder b = OpenApiServer.builder(); + + assertThat(b.requestContentCoding(deflate).responseContentCoding(deflate)).isSameAs(b); + } + + @Test + void contentCodingConflictsWithAnEarlierOneWayRegistration() { + OpenApiServer.Builder b = OpenApiServer.builder().responseContentCoding(deflate()); + ContentCoding bothWays = deflate(); + + assertThatThrownBy(() -> b.contentCoding(bothWays)).isInstanceOf(IllegalStateException.class); + } + @Test void rejectsNegativeShutdownTimeout() { OpenApiServer.Builder b = OpenApiServer.builder(); diff --git a/src/test/java/com/retailsvc/http/internal/AcceptEncodingHeaderTest.java b/src/test/java/com/retailsvc/http/internal/AcceptEncodingHeaderTest.java new file mode 100644 index 0000000..131652e --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/AcceptEncodingHeaderTest.java @@ -0,0 +1,165 @@ +package com.retailsvc.http.internal; + +import static com.retailsvc.http.support.TestCodings.named; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.ContentCoding; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AcceptEncodingHeaderTest { + + private static final ContentCoding GZIP = new GzipCoding(); + private static final ContentCoding ZSTD = named("zstd"); + private static final List ZSTD_THEN_GZIP = List.of(ZSTD, GZIP); + + @Test + void nullHeaderIsNotAccepted() { + assertThat(accepts(null)).isFalse(); + } + + @Test + void blankHeaderIsNotAccepted() { + assertThat(accepts(" ")).isFalse(); + } + + @Test + void plainGzipIsAccepted() { + assertThat(accepts("gzip")).isTrue(); + } + + @Test + void gzipAmongOtherCodingsIsAccepted() { + assertThat(accepts("br, deflate, gzip")).isTrue(); + } + + @Test + void caseInsensitiveGzipIsAccepted() { + assertThat(accepts("GZip")).isTrue(); + } + + @Test + void xGzipIsAccepted() { + assertThat(accepts("x-gzip")).isTrue(); + } + + @Test + void explicitZeroQValueIsRefused() { + assertThat(accepts("gzip;q=0")).isFalse(); + assertThat(accepts("gzip;q=0.0")).isFalse(); + } + + @Test + void positiveQValueIsAccepted() { + assertThat(accepts("gzip;q=0.5")).isTrue(); + } + + @Test + void wildcardIsAccepted() { + assertThat(accepts("*")).isTrue(); + } + + @Test + void wildcardWithZeroQValueIsRefused() { + assertThat(accepts("*;q=0")).isFalse(); + } + + @Test + void explicitGzipBeatsWildcardRefusal() { + assertThat(accepts("gzip, *;q=0")).isTrue(); + } + + @Test + void explicitGzipRefusalBeatsWildcard() { + assertThat(accepts("gzip;q=0, *")).isFalse(); + } + + @Test + void identityOnlyIsNotAccepted() { + assertThat(accepts("identity")).isFalse(); + } + + @Test + void deflateOnlyIsNotAccepted() { + assertThat(accepts("deflate, br")).isFalse(); + } + + @Test + void surroundingWhitespaceIsTolerated() { + assertThat(accepts(" deflate , gzip ; q=0.8 ")).isTrue(); + } + + @Test + void malformedQValueIsTreatedAsAccepted() { + assertThat(accepts("gzip;q=bogus")).isTrue(); + } + + @Test + void emptyTokensAreIgnored() { + assertThat(accepts("deflate,,gzip")).isTrue(); + } + + @Test + void repeatedGzipTokensTakeThePositiveWeight() { + assertThat(accepts("gzip;q=0, gzip")).isTrue(); + assertThat(accepts("gzip, x-gzip;q=0")).isTrue(); + } + + @Test + void repeatedWildcardsTakeThePositiveWeight() { + assertThat(accepts("*;q=0, *")).isTrue(); + } + + @Test + void parametersOtherThanWeightAreIgnored() { + assertThat(accepts("gzip;level=9")).isTrue(); + assertThat(accepts("gzip;level=9;q=0")).isFalse(); + } + + @Test + void valuelessParameterIsIgnored() { + assertThat(accepts("gzip;q")).isTrue(); + } + + // -- choosing among several codings -- + + @Test + void clientWeightOutranksServerPreference() { + assertThat(AcceptEncodingHeader.select("gzip;q=1.0, zstd;q=0.5", ZSTD_THEN_GZIP)) + .contains(GZIP); + } + + @Test + void equalWeightsGoToTheServersFirstChoice() { + assertThat(AcceptEncodingHeader.select("gzip, zstd", ZSTD_THEN_GZIP)).contains(ZSTD); + } + + @Test + void wildcardGoesToTheServersFirstChoice() { + assertThat(AcceptEncodingHeader.select("*", ZSTD_THEN_GZIP)).contains(ZSTD); + } + + @Test + void refusingTheFirstChoiceFallsBackToTheNext() { + assertThat(AcceptEncodingHeader.select("zstd;q=0, gzip", ZSTD_THEN_GZIP)).contains(GZIP); + } + + @Test + void aliasOfALaterChoiceIsHonoured() { + assertThat(AcceptEncodingHeader.select("x-gzip", ZSTD_THEN_GZIP)).contains(GZIP); + } + + @Test + void noSupportedCodingSelectsNothing() { + assertThat(AcceptEncodingHeader.select("br, deflate", ZSTD_THEN_GZIP)).isEmpty(); + } + + @Test + void noCandidatesSelectsNothing() { + assertThat(AcceptEncodingHeader.select("gzip", List.of())).isEmpty(); + } + + private static boolean accepts(String header) { + return AcceptEncodingHeader.select(header, List.of(GZIP)).isPresent(); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/ContentCodingsTest.java b/src/test/java/com/retailsvc/http/internal/ContentCodingsTest.java new file mode 100644 index 0000000..7f7eceb --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ContentCodingsTest.java @@ -0,0 +1,130 @@ +package com.retailsvc.http.internal; + +import static com.retailsvc.http.support.TestCodings.named; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.retailsvc.http.ContentCoding; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ContentCodingsTest { + + @Test + void gzipIsTheOnlyCodingWhenNoneAreRegistered() { + ContentCodings codings = ContentCodings.of(List.of(), List.of()); + + assertThat(codings.encoders()).extracting(ContentCoding::token).containsExactly("gzip"); + assertThat(codings.decoders()).containsOnlyKeys("gzip", "x-gzip"); + } + + @Test + void registeredEncodersArePreferredOverGzipInRegistrationOrder() { + ContentCodings codings = ContentCodings.of(List.of(), List.of(named("zstd"), named("br"))); + + assertThat(codings.encoders()) + .extracting(ContentCoding::token) + .containsExactly("zstd", "br", "gzip"); + } + + @Test + void decodersAreKeyedByTokenAndAlias() { + ContentCoding deflate = named("deflate", "x-deflate"); + + ContentCodings codings = ContentCodings.of(List.of(deflate), List.of()); + + assertThat(codings.decoders()) + .containsEntry("deflate", deflate) + .containsEntry("x-deflate", deflate); + } + + @Test + void requestAndResponseRegistrationsAreIndependent() { + ContentCodings codings = ContentCodings.of(List.of(named("br")), List.of(named("zstd"))); + + assertThat(codings.decoders()).containsKey("br").doesNotContainKey("zstd"); + assertThat(codings.encoders()).extracting(ContentCoding::token).containsExactly("zstd", "gzip"); + } + + @ParameterizedTest + @ValueSource(strings = {"zstd", "br", "x-custom.v2", "a1"}) + void acceptsLowerCaseRfc9110Tokens(String token) { + ContentCoding coding = named(token); + + assertThatCode(() -> ContentCodings.requireRegistrable(coding, List.of())) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "", + "zs td", + "zs,td", + "zs;td", + "zstd\r\nX-Injected: yes", + "ZSTD", + "zs\"td", + "zs/td" + }) + void rejectsTokensThatAreNotLowerCaseRfc9110Tokens(String token) { + ContentCoding coding = named(token); + + assertThatThrownBy(() -> ContentCodings.requireRegistrable(coding, List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"gzip", "x-gzip", "identity", "*"}) + void rejectsReservedTokens(String token) { + ContentCoding coding = named(token); + + assertThatThrownBy(() -> ContentCodings.requireRegistrable(coding, List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(token); + } + + @Test + void rejectsReservedAlias() { + ContentCoding aliasedToGzip = named("fastgzip", "gzip"); + + assertThatThrownBy(() -> ContentCodings.requireRegistrable(aliasedToGzip, List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsNullCoding() { + assertThatThrownBy(() -> ContentCodings.requireRegistrable(null, List.of())) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rejectsNullToken() { + ContentCoding nameless = named(null); + + assertThatThrownBy(() -> ContentCodings.requireRegistrable(nameless, List.of())) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rejectsATokenAlreadyRegistered() { + List registered = List.of(named("zstd")); + ContentCoding duplicate = named("zstd"); + + assertThatThrownBy(() -> ContentCodings.requireRegistrable(duplicate, registered)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("zstd"); + } + + @Test + void rejectsAnAliasThatCollidesWithARegisteredToken() { + List registered = List.of(named("br")); + ContentCoding brotli = named("brotli", "br"); + + assertThatThrownBy(() -> ContentCodings.requireRegistrable(brotli, registered)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/ContentEncodingHeaderTest.java b/src/test/java/com/retailsvc/http/internal/ContentEncodingHeaderTest.java new file mode 100644 index 0000000..caac40d --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ContentEncodingHeaderTest.java @@ -0,0 +1,101 @@ +package com.retailsvc.http.internal; + +import static com.retailsvc.http.support.TestCodings.named; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.ContentCoding; +import com.retailsvc.http.internal.ContentEncodingHeader.RequestCoding; +import com.retailsvc.http.internal.ContentEncodingHeader.RequestCoding.Coded; +import com.retailsvc.http.internal.ContentEncodingHeader.RequestCoding.Identity; +import com.retailsvc.http.internal.ContentEncodingHeader.RequestCoding.Unsupported; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ContentEncodingHeaderTest { + + private static final Map GZIP_ONLY = + ContentCodings.of(List.of(), List.of()).decoders(); + private static final RequestCoding GZIPPED = new Coded(GZIP_ONLY.get("gzip")); + private static final RequestCoding IDENTITY = new Identity(); + private static final RequestCoding UNSUPPORTED = new Unsupported(); + + @Test + void nullHeaderIsIdentity() { + assertThat(parse(null)).isEqualTo(IDENTITY); + } + + @Test + void emptyHeaderIsIdentity() { + assertThat(parse(" ")).isEqualTo(IDENTITY); + } + + @Test + void identityIsNotACoding() { + assertThat(parse("identity")).isEqualTo(IDENTITY); + } + + @Test + void gzipIsGzip() { + assertThat(parse("gzip")).isEqualTo(GZIPPED); + } + + @Test + void xGzipIsGzip() { + assertThat(parse("x-gzip")).isEqualTo(GZIPPED); + } + + @Test + void mixedCaseGzipIsGzip() { + assertThat(parse("GZip")).isEqualTo(GZIPPED); + } + + @Test + void gzipWithIdentityIsGzip() { + assertThat(parse("identity, gzip")).isEqualTo(GZIPPED); + } + + @Test + void surroundingWhitespaceIsTolerated() { + assertThat(parse(" gzip ")).isEqualTo(GZIPPED); + } + + @Test + void brotliIsUnsupported() { + assertThat(parse("br")).isEqualTo(UNSUPPORTED); + } + + @Test + void deflateIsUnsupported() { + assertThat(parse("deflate")).isEqualTo(UNSUPPORTED); + } + + @Test + void stackedCodingsAreUnsupported() { + assertThat(parse("gzip, gzip")).isEqualTo(UNSUPPORTED); + assertThat(parse("gzip, br")).isEqualTo(UNSUPPORTED); + } + + @Test + void registeredCodingIsRecognised() { + ContentCoding zstd = named("zstd"); + + RequestCoding coding = ContentEncodingHeader.parse("zstd", decoders(zstd)); + + assertThat(coding).isEqualTo(new Coded(zstd)); + } + + @Test + void stackingARegisteredCodingIsStillUnsupported() { + assertThat(ContentEncodingHeader.parse("zstd, gzip", decoders(named("zstd")))) + .isEqualTo(UNSUPPORTED); + } + + private static RequestCoding parse(String header) { + return ContentEncodingHeader.parse(header, GZIP_ONLY); + } + + private static Map decoders(ContentCoding coding) { + return ContentCodings.of(List.of(coding), List.of()).decoders(); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index 2c03873..9624c5f 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -1,5 +1,6 @@ package com.retailsvc.http.internal; +import static com.retailsvc.http.internal.ResponseRenderer.DEFAULT_MIN_COMPRESSIBLE_BYTES; import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_OK; import static org.assertj.core.api.Assertions.assertThat; @@ -29,6 +30,7 @@ class DispatchHandlerTest { private static HttpExchange stubExchange() { HttpExchange exchange = mock(HttpExchange.class); + when(exchange.getRequestHeaders()).thenReturn(new Headers()); when(exchange.getResponseHeaders()).thenReturn(new Headers()); Map attrs = new HashMap<>(); doAnswer( @@ -43,14 +45,28 @@ private static HttpExchange stubExchange() { } private static DispatchHandler dispatcher(Map handlers) { - return new DispatchHandler(handlers, List.of(), List.of(), new ResponseRenderer(Map.of())); + return new DispatchHandler( + handlers, + List.of(), + List.of(), + new ResponseRenderer( + Map.of(), + DEFAULT_MIN_COMPRESSIBLE_BYTES, + ContentCodings.of(List.of(), List.of()).encoders())); } private static DispatchHandler dispatcher( Map handlers, List interceptors, List decorators) { - return new DispatchHandler(handlers, interceptors, decorators, new ResponseRenderer(Map.of())); + return new DispatchHandler( + handlers, + interceptors, + decorators, + new ResponseRenderer( + Map.of(), + DEFAULT_MIN_COMPRESSIBLE_BYTES, + ContentCodings.of(List.of(), List.of()).encoders())); } private static void withRequest(String operationId, ScopedValue.CallableOp body) diff --git a/src/test/java/com/retailsvc/http/internal/ExtrasRouterTest.java b/src/test/java/com/retailsvc/http/internal/ExtrasRouterTest.java index b8c17e7..29c8961 100644 --- a/src/test/java/com/retailsvc/http/internal/ExtrasRouterTest.java +++ b/src/test/java/com/retailsvc/http/internal/ExtrasRouterTest.java @@ -1,5 +1,6 @@ package com.retailsvc.http.internal; +import static com.retailsvc.http.internal.ResponseRenderer.DEFAULT_MIN_COMPRESSIBLE_BYTES; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; @@ -15,10 +16,14 @@ import com.sun.net.httpserver.HttpExchange; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPOutputStream; import org.junit.jupiter.api.Test; class ExtrasRouterTest { @@ -120,19 +125,58 @@ void traversalRejected() { .isInstanceOf(BadRequestException.class); } + @Test + void gzipRequestBodyIsInflatedForExtraRoutes() throws Exception { + AtomicReference seen = new AtomicReference<>(); + Map extras = new LinkedHashMap<>(); + extras.put( + "/echo", + req -> { + seen.set(new String(req.bytes(), StandardCharsets.UTF_8)); + return Response.empty(); + }); + Headers headers = new Headers(); + headers.add("Content-Encoding", "gzip"); + + invoke(newRouter(extras), "/echo", gzip("hello".getBytes(StandardCharsets.UTF_8)), headers); + + assertThat(seen.get()).isEqualTo("hello"); + } + private static ExtrasRouter newRouter(Map extras) { Map mappers = Map.of("application/json", new GsonTypeMapper()); - return new ExtrasRouter(extras, new ResponseRenderer(mappers)); + return new ExtrasRouter( + extras, + new ResponseRenderer( + mappers, + DEFAULT_MIN_COMPRESSIBLE_BYTES, + ContentCodings.of(List.of(), List.of()).encoders()), + new RequestBodyReader( + RequestBodyReader.DEFAULT_MAX_DECOMPRESSED_BYTES, + ContentCodings.of(List.of(), List.of()).decoders())); } private static void invoke(ExtrasRouter router, String path) throws Exception { + invoke(router, path, new byte[0], new Headers()); + } + + private static void invoke(ExtrasRouter router, String path, byte[] body, Headers headers) + throws Exception { HttpExchange ex = mock(HttpExchange.class); when(ex.getRequestMethod()).thenReturn("GET"); when(ex.getRequestURI()).thenReturn(URI.create(path)); - when(ex.getRequestHeaders()).thenReturn(new Headers()); - when(ex.getRequestBody()).thenReturn(new ByteArrayInputStream(new byte[0])); + when(ex.getRequestHeaders()).thenReturn(headers); + when(ex.getRequestBody()).thenReturn(new ByteArrayInputStream(body)); when(ex.getResponseHeaders()).thenReturn(new Headers()); when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); router.handle(ex); } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write(data); + } + return out.toByteArray(); + } } diff --git a/src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java b/src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java index f00aba5..346f705 100644 --- a/src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java +++ b/src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java @@ -1,5 +1,7 @@ package com.retailsvc.http.internal; +import static java.net.HttpURLConnection.HTTP_ENTITY_TOO_LARGE; +import static java.net.HttpURLConnection.HTTP_UNSUPPORTED_TYPE; import static org.assertj.core.api.Assertions.assertThat; import com.retailsvc.http.BadRequestException; @@ -106,4 +108,17 @@ void badRequestWithPointerBecomesSingleEntry() { var pd = ProblemDetail.forBadRequest(new BadRequestException(409, "taken", "/email", "unique")); assertThat(pd.errors()).containsExactly(new Entry("#/email", "unique", "taken")); } + + @Test + void contentTooLargeHasItsOwnTitle() { + var pd = ProblemDetail.forBadRequest(new BadRequestException(HTTP_ENTITY_TOO_LARGE, "too big")); + assertThat(pd.title()).isEqualTo("Content Too Large"); + assertThat(pd.status()).isEqualTo(HTTP_ENTITY_TOO_LARGE); + } + + @Test + void unsupportedMediaTypeKeepsItsTitle() { + var pd = ProblemDetail.forBadRequest(new BadRequestException(HTTP_UNSUPPORTED_TYPE, "nope")); + assertThat(pd.title()).isEqualTo("Unsupported Media Type"); + } } diff --git a/src/test/java/com/retailsvc/http/internal/RequestBodyReaderTest.java b/src/test/java/com/retailsvc/http/internal/RequestBodyReaderTest.java new file mode 100644 index 0000000..2cb1c4a --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/RequestBodyReaderTest.java @@ -0,0 +1,256 @@ +package com.retailsvc.http.internal; + +import static com.retailsvc.http.support.TestCodings.deflate; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_ENTITY_TOO_LARGE; +import static java.net.HttpURLConnection.HTTP_UNSUPPORTED_TYPE; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.retailsvc.http.BadRequestException; +import com.retailsvc.http.ContentCoding; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipException; +import org.junit.jupiter.api.Test; + +class RequestBodyReaderTest { + + private static final long CAP = 1024; + private static final Map GZIP_ONLY = + ContentCodings.of(List.of(), List.of()).decoders(); + private final RequestBodyReader reader = new RequestBodyReader(CAP, GZIP_ONLY); + private final RequestBodyReader withDeflate = + new RequestBodyReader(CAP, ContentCodings.of(List.of(deflate()), List.of()).decoders()); + + @Test + void constructorRejectsNonPositiveCap() { + assertThatThrownBy(() -> new RequestBodyReader(0, GZIP_ONLY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxDecompressedBytes"); + assertThatThrownBy(() -> new RequestBodyReader(-1, GZIP_ONLY)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void plainBodyIsReturnedUnchanged() throws IOException { + byte[] raw = "hello".getBytes(UTF_8); + + RequestBodyReader.Body body = reader.read(exchange(raw, null)); + + assertThat(body.bytes()).isEqualTo(raw); + } + + @Test + void identityCodedBodyIsReturnedUnchanged() throws IOException { + byte[] raw = "hello".getBytes(UTF_8); + + RequestBodyReader.Body body = reader.read(exchange(raw, "identity")); + + assertThat(body.bytes()).isEqualTo(raw); + } + + @Test + void gzipBodyIsInflated() throws IOException { + byte[] plain = "hello gzip".getBytes(UTF_8); + + RequestBodyReader.Body body = reader.read(exchange(gzip(plain), "gzip")); + + assertThat(body.bytes()).isEqualTo(plain); + } + + @Test + void emptyGzipBodyIsReturnedEmpty() throws IOException { + RequestBodyReader.Body body = reader.read(exchange(new byte[0], "gzip")); + + assertThat(body.bytes()).isEmpty(); + } + + @Test + void unsupportedCodingThrows415() { + HttpExchange brotli = exchange("x".getBytes(UTF_8), "br"); + + assertThatThrownBy(() -> reader.read(brotli)) + .isInstanceOfSatisfying( + BadRequestException.class, + e -> assertThat(e.status()).isEqualTo(HTTP_UNSUPPORTED_TYPE)); + } + + @Test + void oversizedInflatedBodyThrows413() throws IOException { + HttpExchange bomb = exchange(gzip(new byte[(int) CAP * 4]), "gzip"); + + assertThatThrownBy(() -> reader.read(bomb)) + .isInstanceOfSatisfying( + BadRequestException.class, + e -> assertThat(e.status()).isEqualTo(HTTP_ENTITY_TOO_LARGE)); + } + + @Test + void bodyExactlyAtCapIsAccepted() throws IOException { + byte[] atLimit = new byte[(int) CAP]; + + RequestBodyReader.Body body = reader.read(exchange(gzip(atLimit), "gzip")); + + assertThat(body.bytes()).hasSize((int) CAP); + } + + @Test + void malformedGzipThrows400WithCause() { + HttpExchange garbage = exchange("not gzip at all".getBytes(UTF_8), "gzip"); + + assertThatThrownBy(() -> reader.read(garbage)) + .isInstanceOfSatisfying( + BadRequestException.class, + e -> { + assertThat(e.status()).isEqualTo(HTTP_BAD_REQUEST); + assertThat(e.getCause()).isInstanceOf(ZipException.class); + }); + } + + @Test + void truncatedGzipThrows400() throws IOException { + byte[] complete = gzip("some reasonably long payload to truncate".getBytes(UTF_8)); + HttpExchange truncated = exchange(Arrays.copyOf(complete, complete.length - 6), "gzip"); + + assertThatThrownBy(() -> reader.read(truncated)) + .isInstanceOfSatisfying( + BadRequestException.class, e -> assertThat(e.status()).isEqualTo(HTTP_BAD_REQUEST)); + } + + @Test + void decodedBodyHidesContentEncodingHeader() throws IOException { + RequestBodyReader.Body body = reader.read(exchange(gzip("hi".getBytes(UTF_8)), "gzip")); + + assertThat(body.headerLookup().apply("Content-Encoding")).isNull(); + assertThat(body.headerLookup().apply("content-encoding")).isNull(); + } + + @Test + void decodedBodyReportsInflatedContentLength() throws IOException { + byte[] plain = "hello gzip".getBytes(UTF_8); + + RequestBodyReader.Body body = reader.read(exchange(gzip(plain), "gzip")); + + assertThat(body.headerLookup().apply("Content-Length")).isEqualTo(String.valueOf(plain.length)); + } + + @Test + void decodedBodyLeavesOtherHeadersVisible() throws IOException { + RequestBodyReader.Body body = reader.read(exchange(gzip("hi".getBytes(UTF_8)), "gzip")); + + assertThat(body.headerLookup().apply("X-Custom")).isEqualTo("value"); + } + + @Test + void plainBodyKeepsOriginalHeaderLookup() throws IOException { + RequestBodyReader.Body body = reader.read(exchange("hi".getBytes(UTF_8), "identity")); + + assertThat(body.headerLookup().apply("Content-Encoding")).isEqualTo("identity"); + assertThat(body.headerLookup().apply("X-Custom")).isEqualTo("value"); + } + + // -- registered codings -- + + @Test + void registeredCodingIsDecoded() throws IOException { + byte[] plain = "hello deflate".getBytes(UTF_8); + + RequestBodyReader.Body body = withDeflate.read(exchange(deflated(plain), "deflate")); + + assertThat(body.bytes()).isEqualTo(plain); + } + + @Test + void capAppliesToARegisteredCoding() throws IOException { + HttpExchange bomb = exchange(deflated(new byte[(int) CAP * 4]), "deflate"); + + assertThatThrownBy(() -> withDeflate.read(bomb)) + .isInstanceOfSatisfying( + BadRequestException.class, + e -> assertThat(e.status()).isEqualTo(HTTP_ENTITY_TOO_LARGE)); + } + + @Test + void malformedBodyNamesTheRegisteredCoding() { + HttpExchange garbage = exchange("not deflate at all".getBytes(UTF_8), "deflate"); + + assertThatThrownBy(() -> withDeflate.read(garbage)) + .isInstanceOfSatisfying( + BadRequestException.class, + e -> { + assertThat(e.status()).isEqualTo(HTTP_BAD_REQUEST); + assertThat(e.getMessage()).contains("deflate"); + }); + } + + @Test + void faultInACodingIsNotReportedAsAClientError() { + ContentCoding broken = + new ContentCoding() { + @Override + public String token() { + return "broken"; + } + + @Override + public InputStream decode(InputStream coded) { + throw new IllegalStateException("bug in the coding"); + } + + @Override + public OutputStream encode(OutputStream sink) { + return sink; + } + }; + RequestBodyReader faulty = + new RequestBodyReader(CAP, ContentCodings.of(List.of(broken), List.of()).decoders()); + HttpExchange request = exchange("x".getBytes(UTF_8), "broken"); + + assertThatThrownBy(() -> faulty.read(request)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("bug in the coding"); + } + + private static HttpExchange exchange(byte[] body, String contentEncoding) { + Headers headers = new Headers(); + if (contentEncoding != null) { + headers.add("Content-Encoding", contentEncoding); + } + headers.add("Content-Length", String.valueOf(body.length)); + headers.add("X-Custom", "value"); + HttpExchange exchange = mock(HttpExchange.class); + when(exchange.getRequestHeaders()).thenReturn(headers); + when(exchange.getRequestBody()).thenReturn(new ByteArrayInputStream(body)); + return exchange; + } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write(data); + } + return out.toByteArray(); + } + + private static byte[] deflated(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (DeflaterOutputStream deflater = new DeflaterOutputStream(out)) { + deflater.write(data); + } + return out.toByteArray(); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index d37ee9c..cd64f78 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -1,9 +1,16 @@ package com.retailsvc.http.internal; +import static com.retailsvc.http.internal.ResponseRenderer.DEFAULT_MIN_COMPRESSIBLE_BYTES; +import static java.net.HttpURLConnection.HTTP_UNSUPPORTED_TYPE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.retailsvc.http.BadRequestException; import com.retailsvc.http.ExceptionHandler; import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; @@ -27,6 +34,8 @@ import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -34,20 +43,38 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPOutputStream; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; class RequestPreparationFilterTest { private HttpExchange exchange(String method, String path, byte[] body) { + return exchange(method, path, body, new Headers()); + } + + private HttpExchange exchange(String method, String path, byte[] body, Headers headers) { HttpExchange ex = mock(HttpExchange.class); - Mockito.when(ex.getRequestMethod()).thenReturn(method); - Mockito.when(ex.getRequestURI()).thenReturn(URI.create(path)); - Mockito.when(ex.getRequestHeaders()).thenReturn(new Headers()); - Mockito.when(ex.getRequestBody()).thenReturn(new ByteArrayInputStream(body)); + when(ex.getRequestMethod()).thenReturn(method); + when(ex.getRequestURI()).thenReturn(URI.create(path)); + when(ex.getRequestHeaders()).thenReturn(headers); + when(ex.getRequestBody()).thenReturn(new ByteArrayInputStream(body)); return ex; } + private static Headers headers(String name, String value) { + Headers headers = new Headers(); + headers.add(name, value); + return headers; + } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write(data); + } + return out.toByteArray(); + } + private Spec specWith(Operation... ops) { return new Spec( "3.1.0", @@ -91,8 +118,14 @@ public byte[] writeTo(Object value) { new DefaultValidator(spec::resolveSchema), mappers, rethrow, - new ResponseRenderer(mappers), - List.of()); + new ResponseRenderer( + mappers, + DEFAULT_MIN_COMPRESSIBLE_BYTES, + ContentCodings.of(List.of(), List.of()).encoders()), + List.of(), + new RequestBodyReader( + RequestBodyReader.DEFAULT_MAX_DECOMPRESSED_BYTES, + ContentCodings.of(List.of(), List.of()).decoders())); } @Test @@ -115,7 +148,7 @@ void successPathBindsRequestContextDuringChain() throws Exception { AtomicReference> seenPathParams = new AtomicReference<>(); Filter.Chain chain = mock(Filter.Chain.class); - Mockito.doAnswer( + doAnswer( inv -> { Request req = DispatchHandler.CURRENT.get(); seenOpId.set(req.operationId()); @@ -123,13 +156,13 @@ void successPathBindsRequestContextDuringChain() throws Exception { return null; }) .when(chain) - .doFilter(Mockito.any()); + .doFilter(any()); f.doFilter(ex, chain); assertThat(seenOpId.get()).isEqualTo("get-user"); assertThat(seenPathParams.get()).containsEntry("id", "42"); - Mockito.verify(chain).doFilter(ex); + verify(chain).doFilter(ex); } @Test @@ -216,7 +249,7 @@ void integerQueryParamIsCoercedFromStringBeforeValidation() throws Exception { HttpExchange ex = exchange("GET", "/x?n=42", new byte[0]); Filter.Chain chain = mock(Filter.Chain.class); f.doFilter(ex, chain); - Mockito.verify(chain).doFilter(ex); + verify(chain).doFilter(ex); } @Test @@ -263,7 +296,7 @@ void numberQueryParamIsCoercedFromStringBeforeValidation() throws Exception { HttpExchange ex = exchange("GET", "/x?n=1.5", new byte[0]); Filter.Chain chain = mock(Filter.Chain.class); f.doFilter(ex, chain); - Mockito.verify(chain).doFilter(ex); + verify(chain).doFilter(ex); } @Test @@ -312,8 +345,8 @@ void booleanQueryParamCoercesTrueAndFalse() throws Exception { HttpExchange falseEx = exchange("GET", "/x?b=false", new byte[0]); f.doFilter(trueEx, trueChain); f.doFilter(falseEx, falseChain); - Mockito.verify(trueChain).doFilter(trueEx); - Mockito.verify(falseChain).doFilter(falseEx); + verify(trueChain).doFilter(trueEx); + verify(falseChain).doFilter(falseEx); } @Test @@ -338,4 +371,60 @@ void booleanQueryParamRejectsNonBooleanString() { .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("type"); } + + @Test + void gzipRequestBodyIsInflatedBeforeValidation() throws Exception { + var op = + new Operation( + "get-x", + HttpMethod.GET, + PathTemplate.compile("/x"), + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.empty()); + Filter f = newFilter(specWith(op)); + byte[] plain = "hello gzip".getBytes(StandardCharsets.UTF_8); + HttpExchange ex = exchange("GET", "/x", gzip(plain), headers("Content-Encoding", "gzip")); + + AtomicReference seenBody = new AtomicReference<>(); + AtomicReference seenEncoding = new AtomicReference<>("still here"); + Filter.Chain chain = mock(Filter.Chain.class); + doAnswer( + inv -> { + Request req = DispatchHandler.CURRENT.get(); + seenBody.set(req.bytes()); + seenEncoding.set(req.header("Content-Encoding").orElse(null)); + return null; + }) + .when(chain) + .doFilter(any()); + + f.doFilter(ex, chain); + + assertThat(seenBody.get()).isEqualTo(plain); + assertThat(seenEncoding.get()).isNull(); + } + + @Test + void unsupportedRequestCodingIsRejectedBeforeRouting() { + var op = + new Operation( + "get-x", + HttpMethod.GET, + PathTemplate.compile("/x"), + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.empty()); + Filter f = newFilter(specWith(op)); + HttpExchange ex = exchange("GET", "/missing", new byte[0], headers("Content-Encoding", "br")); + + assertThatThrownBy(() -> f.doFilter(ex, mock(Filter.Chain.class))) + .isInstanceOfSatisfying( + BadRequestException.class, + e -> assertThat(e.status()).isEqualTo(HTTP_UNSUPPORTED_TYPE)); + } } diff --git a/src/test/java/com/retailsvc/http/internal/ResponseCompressionTest.java b/src/test/java/com/retailsvc/http/internal/ResponseCompressionTest.java new file mode 100644 index 0000000..93d476c --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ResponseCompressionTest.java @@ -0,0 +1,80 @@ +package com.retailsvc.http.internal; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.zip.GZIPInputStream; +import org.junit.jupiter.api.Test; + +class ResponseCompressionTest { + + @Test + void nullContentTypeIsNotCompressible() { + assertThat(ResponseCompression.isCompressible(null)).isFalse(); + } + + @Test + void jsonIsCompressible() { + assertThat(ResponseCompression.isCompressible("application/json")).isTrue(); + } + + @Test + void problemJsonIsCompressible() { + assertThat(ResponseCompression.isCompressible("application/problem+json")).isTrue(); + } + + @Test + void yamlIsCompressible() { + assertThat(ResponseCompression.isCompressible("application/yaml")).isTrue(); + assertThat(ResponseCompression.isCompressible("application/x-yaml")).isTrue(); + } + + @Test + void textPlainWithCharsetIsCompressible() { + assertThat(ResponseCompression.isCompressible("text/plain; charset=utf-8")).isTrue(); + } + + @Test + void xmlSuffixIsCompressible() { + assertThat(ResponseCompression.isCompressible("image/svg+xml")).isTrue(); + assertThat(ResponseCompression.isCompressible("application/xml")).isTrue(); + } + + @Test + void formUrlEncodedIsNotCompressible() { + assertThat(ResponseCompression.isCompressible("application/octet-stream")).isFalse(); + } + + @Test + void imagePngIsNotCompressible() { + assertThat(ResponseCompression.isCompressible("image/png")).isFalse(); + } + + @Test + void eventStreamIsNotCompressible() { + assertThat(ResponseCompression.isCompressible("text/event-stream")).isFalse(); + } + + @Test + void matchIsCaseInsensitive() { + assertThat(ResponseCompression.isCompressible("Application/JSON")).isTrue(); + } + + @Test + void gzipRoundTripsBytes() throws IOException { + byte[] plain = "round trip me".repeat(20).getBytes(UTF_8); + + byte[] compressed = ResponseCompression.encode(new GzipCoding(), plain); + + assertThat(compressed).isNotEqualTo(plain); + assertThat(gunzip(compressed)).isEqualTo(plain); + } + + private static byte[] gunzip(byte[] data) throws IOException { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(data))) { + return in.readAllBytes(); + } + } +} diff --git a/src/test/java/com/retailsvc/http/internal/ResponseRendererTest.java b/src/test/java/com/retailsvc/http/internal/ResponseRendererTest.java new file mode 100644 index 0000000..ad8d24e --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ResponseRendererTest.java @@ -0,0 +1,531 @@ +package com.retailsvc.http.internal; + +import static com.retailsvc.http.support.TestCodings.deflate; +import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.retailsvc.http.ContentCoding; +import com.retailsvc.http.GsonTypeMapper; +import com.retailsvc.http.Response; +import com.retailsvc.http.TypeMapper; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ResponseRendererTest { + + private static final Map MAPPERS = + Map.of("application/json", new GsonTypeMapper()); + private static final long THRESHOLD = 1024; + private static final List GZIP_ONLY = + ContentCodings.of(List.of(), List.of()).encoders(); + private static final String JSON = "application/json"; + private static final String TEXT = "text/plain"; + private static final String CONTENT_ENCODING = "Content-Encoding"; + private static final String VARY = "Vary"; + + private final ResponseRenderer renderer = new ResponseRenderer(MAPPERS, THRESHOLD, GZIP_ONLY); + private final Headers requestHeaders = new Headers(); + private final Headers responseHeaders = new Headers(); + private final ByteArrayOutputStream sink = new ByteArrayOutputStream(); + private final AtomicInteger status = new AtomicInteger(); + private final AtomicLong length = new AtomicLong(); + private HttpExchange exchange; + + @BeforeEach + void setUp() throws IOException { + exchange = mock(HttpExchange.class); + when(exchange.getRequestHeaders()).thenReturn(requestHeaders); + when(exchange.getResponseHeaders()).thenReturn(responseHeaders); + when(exchange.getResponseBody()).thenReturn(sink); + doAnswer( + invocation -> { + status.set(invocation.getArgument(0)); + length.set(invocation.getArgument(1)); + return null; + }) + .when(exchange) + .sendResponseHeaders(anyInt(), anyLong()); + } + + // -- baseline behaviour -- + + @Test + void writesBytesWithContentLength() throws IOException { + renderer.render(exchange, Response.bytes(HTTP_OK, "abc".getBytes(UTF_8), TEXT)); + + assertThat(status.get()).isEqualTo(HTTP_OK); + assertThat(length.get()).isEqualTo(3); + assertThat(sink.toByteArray()).isEqualTo("abc".getBytes(UTF_8)); + assertThat(responseHeaders.getFirst("Content-Type")).isEqualTo(TEXT); + } + + @Test + void writesNullBodyWithMinusOne() throws IOException { + renderer.render(exchange, Response.status(HTTP_NO_CONTENT)); + + assertThat(length.get()).isEqualTo(-1); + assertThat(sink.toByteArray()).isEmpty(); + } + + @Test + void writesEmptyByteBodyWithMinusOne() throws IOException { + renderer.render(exchange, Response.bytes(HTTP_OK, new byte[0], TEXT)); + + assertThat(length.get()).isEqualTo(-1); + } + + // -- compression -- + + @Test + void compressesJsonBodyOverThreshold() throws IOException { + acceptsGzip(); + byte[] body = largeText(); + + renderer.render(exchange, Response.bytes(HTTP_OK, body, JSON)); + + assertThat(gunzip(sink.toByteArray())).isEqualTo(body); + } + + @Test + void setsContentEncodingGzipWhenCompressed() throws IOException { + acceptsGzip(); + + renderer.render(exchange, Response.bytes(HTTP_OK, largeText(), JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("gzip"); + assertThat(responseHeaders.getFirst(VARY)).contains("Accept-Encoding"); + } + + @Test + void sentContentLengthMatchesCompressedPayload() throws IOException { + acceptsGzip(); + + renderer.render(exchange, Response.bytes(HTTP_OK, largeText(), JSON)); + + assertThat(length.get()).isEqualTo(sink.toByteArray().length); + assertThat(length.get()).isLessThan(largeText().length); + } + + @Test + void skipsCompressionBelowThreshold() throws IOException { + acceptsGzip(); + byte[] body = "{\"id\":\"small\"}".getBytes(UTF_8); + + renderer.render(exchange, Response.bytes(HTTP_OK, body, JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + assertThat(sink.toByteArray()).isEqualTo(body); + } + + @Test + void skipsCompressionForOctetStream() throws IOException { + acceptsGzip(); + byte[] body = largeText(); + + renderer.render(exchange, Response.bytes(HTTP_OK, body, "application/octet-stream")); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + assertThat(sink.toByteArray()).isEqualTo(body); + } + + @Test + void skipsCompressionWithoutAcceptEncoding() throws IOException { + byte[] body = largeText(); + + renderer.render(exchange, Response.bytes(HTTP_OK, body, JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + assertThat(sink.toByteArray()).isEqualTo(body); + } + + @Test + void skipsCompressionWhenGzipRefusedByQValue() throws IOException { + requestHeaders.add("Accept-Encoding", "gzip;q=0"); + + renderer.render(exchange, Response.bytes(HTTP_OK, largeText(), JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + } + + @Test + void skipsCompressionWhenHandlerAlreadySetContentEncoding() throws IOException { + acceptsGzip(); + byte[] body = largeText(); + + renderer.render( + exchange, Response.bytes(HTTP_OK, body, JSON).withHeader(CONTENT_ENCODING, "br")); + + assertThat(responseHeaders.get(CONTENT_ENCODING)).containsExactly("br"); + assertThat(sink.toByteArray()).isEqualTo(body); + } + + @Test + void addsVaryEvenWhenNotCompressed() throws IOException { + renderer.render(exchange, Response.bytes(HTTP_OK, "{}".getBytes(UTF_8), JSON)); + + assertThat(responseHeaders.getFirst(VARY)).isEqualTo("Accept-Encoding"); + } + + @Test + void appendsVaryToExistingValue() throws IOException { + acceptsGzip(); + + renderer.render( + exchange, Response.bytes(HTTP_OK, largeText(), JSON).withHeader(VARY, "Origin")); + + assertThat(responseHeaders.get(VARY)).hasSize(1); + assertThat(responseHeaders.getFirst(VARY)).isEqualTo("Origin, Accept-Encoding"); + } + + @Test + void doesNotDuplicateVary() throws IOException { + acceptsGzip(); + + renderer.render( + exchange, Response.bytes(HTTP_OK, largeText(), JSON).withHeader(VARY, "Accept-Encoding")); + + assertThat(responseHeaders.get(VARY)).containsExactly("Accept-Encoding"); + } + + @Test + void neverCompressesNoContentResponses() throws IOException { + acceptsGzip(); + + renderer.render(exchange, Response.bytes(HTTP_NO_CONTENT, largeText(), JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + } + + @Test + void fallsBackToPlainBytesWhenGzipIsLarger() throws IOException { + acceptsGzip(); + byte[] incompressible = new byte[2048]; + new Random(42).nextBytes(incompressible); + + renderer.render(exchange, Response.bytes(HTTP_OK, incompressible, TEXT)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + assertThat(sink.toByteArray()).isEqualTo(incompressible); + } + + @Test + void serializedBodyIsCompressed() throws IOException { + acceptsGzip(); + + renderer.render(exchange, Response.ok(Map.of("blob", "x".repeat(4096)))); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("gzip"); + assertThat(new String(gunzip(sink.toByteArray()), UTF_8)).contains("xxxx"); + } + + // -- streaming and empty bodies -- + + @Test + void writesSizedStreamWithLength() throws IOException { + renderer.render( + exchange, Response.stream(HTTP_OK, 3, TEXT, out -> out.write("abc".getBytes(UTF_8)))); + + assertThat(length.get()).isEqualTo(3); + assertThat(sink.toByteArray()).isEqualTo("abc".getBytes(UTF_8)); + } + + @Test + void compressesChunkedStreamWhenAcceptEncodingPresent() throws IOException { + acceptsGzip(); + byte[] payload = largeText(); + + renderer.render(exchange, Response.stream(HTTP_OK, TEXT, out -> out.write(payload))); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("gzip"); + assertThat(length.get()).isZero(); + assertThat(gunzip(sink.toByteArray())).isEqualTo(payload); + } + + @Test + void degradesSizedStreamToChunkedWhenCompressed() throws IOException { + acceptsGzip(); + byte[] payload = largeText(); + + renderer.render( + exchange, Response.stream(HTTP_OK, payload.length, TEXT, out -> out.write(payload))); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("gzip"); + assertThat(length.get()).isZero(); + assertThat(gunzip(sink.toByteArray())).isEqualTo(payload); + } + + @Test + void skipsCompressionForSizedStreamBelowThreshold() throws IOException { + acceptsGzip(); + + renderer.render( + exchange, Response.stream(HTTP_OK, 3, TEXT, out -> out.write("abc".getBytes(UTF_8)))); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + assertThat(length.get()).isEqualTo(3); + assertThat(sink.toByteArray()).isEqualTo("abc".getBytes(UTF_8)); + } + + @Test + void skipsCompressionForNullContentTypeStream() throws IOException { + acceptsGzip(); + byte[] payload = largeText(); + + renderer.render(exchange, Response.stream(HTTP_OK, null, out -> out.write(payload))); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + assertThat(sink.toByteArray()).isEqualTo(payload); + } + + @Test + void emitsContentTypeOnNullBodyResponses() throws IOException { + renderer.render(exchange, Response.status(HTTP_OK).withContentType("application/yaml")); + + assertThat(responseHeaders.getFirst("Content-Type")).isEqualTo("application/yaml"); + assertThat(length.get()).isEqualTo(-1); + } + + @Test + void stripsContentLengthOnNullBodyWhenGetWouldCompress() throws IOException { + acceptsGzip(); + + renderer.render( + exchange, + Response.status(HTTP_OK) + .withContentType("application/yaml") + .withHeader("Content-Length", "8506")); + + assertThat(responseHeaders.getFirst("Content-Length")).isNull(); + assertThat(responseHeaders.getFirst(VARY)).isEqualTo("Accept-Encoding"); + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + } + + @Test + void keepsContentLengthOnNullBodyWhenClientDoesNotAcceptGzip() throws IOException { + renderer.render( + exchange, + Response.status(HTTP_OK) + .withContentType("application/yaml") + .withHeader("Content-Length", "8506")); + + assertThat(responseHeaders.getFirst("Content-Length")).isEqualTo("8506"); + } + + @Test + void keepsContentLengthOnNullBodyForNonCompressibleType() throws IOException { + acceptsGzip(); + + renderer.render( + exchange, + Response.status(HTTP_OK).withContentType("image/png").withHeader("Content-Length", "8506")); + + assertThat(responseHeaders.getFirst("Content-Length")).isEqualTo("8506"); + } + + @Test + void keepsContentLengthOnNullBodyBelowThreshold() throws IOException { + acceptsGzip(); + + renderer.render( + exchange, + Response.status(HTTP_OK).withContentType(TEXT).withHeader("Content-Length", "12")); + + assertThat(responseHeaders.getFirst("Content-Length")).isEqualTo("12"); + } + + @Test + void neverCompressesNotModifiedResponses() throws IOException { + acceptsGzip(); + + renderer.render(exchange, Response.bytes(HTTP_NOT_MODIFIED, largeText(), JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + } + + @Test + void keepsContentLengthOnNullBodyWhenUnparsable() throws IOException { + acceptsGzip(); + + renderer.render( + exchange, + Response.status(HTTP_OK) + .withContentType(TEXT) + .withHeader("Content-Length", "not-a-number")); + + assertThat(responseHeaders.getFirst("Content-Length")).isEqualTo("not-a-number"); + } + + @Test + void keepsHandlerSuppliedContentTypeOnStreams() throws IOException { + acceptsGzip(); + byte[] payload = largeText(); + + renderer.render( + exchange, + Response.stream(HTTP_OK, TEXT, out -> out.write(payload)) + .withHeader("Content-Type", "text/csv")); + + assertThat(responseHeaders.get("Content-Type")).containsExactly("text/csv"); + assertThat(gunzip(sink.toByteArray())).isEqualTo(payload); + } + + @Test + void skipsCompressionOnStreamWhenHandlerAlreadySetContentEncoding() throws IOException { + acceptsGzip(); + byte[] payload = largeText(); + + renderer.render( + exchange, + Response.stream(HTTP_OK, TEXT, out -> out.write(payload)) + .withHeader(CONTENT_ENCODING, "br")); + + assertThat(responseHeaders.get(CONTENT_ENCODING)).containsExactly("br"); + assertThat(sink.toByteArray()).isEqualTo(payload); + } + + @Test + void stripsHandlerContentLengthWhenStreamIsCompressed() throws IOException { + acceptsGzip(); + byte[] payload = largeText(); + + renderer.render( + exchange, + Response.stream(HTTP_OK, payload.length, TEXT, out -> out.write(payload)) + .withHeader("Content-Length", String.valueOf(payload.length))); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("gzip"); + assertThat(responseHeaders.getFirst("Content-Length")).isNull(); + assertThat(length.get()).isZero(); + } + + // -- registered codings -- + + @Test + void registeredCodingCodesTheBodyAndNamesItself() throws IOException { + requestHeaders.add("Accept-Encoding", "deflate"); + byte[] body = largeText(); + + withDeflate().render(exchange, Response.bytes(HTTP_OK, body, JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("deflate"); + assertThat(inflate(sink.toByteArray())).isEqualTo(body); + } + + @Test + void registeredCodingCodesAStream() throws IOException { + requestHeaders.add("Accept-Encoding", "deflate"); + byte[] payload = largeText(); + + withDeflate().render(exchange, Response.stream(HTTP_OK, TEXT, out -> out.write(payload))); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("deflate"); + assertThat(length.get()).isZero(); + assertThat(inflate(sink.toByteArray())).isEqualTo(payload); + } + + @Test + void registeredCodingWinsOverGzipWhenWeightedEqually() throws IOException { + requestHeaders.add("Accept-Encoding", "gzip, deflate"); + + withDeflate().render(exchange, Response.bytes(HTTP_OK, largeText(), JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("deflate"); + } + + @Test + void gzipStillServesClientsThatOnlyTakeGzip() throws IOException { + requestHeaders.add("Accept-Encoding", "gzip"); + + withDeflate().render(exchange, Response.bytes(HTTP_OK, largeText(), JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isEqualTo("gzip"); + } + + @Test + void codingThatDoesNotShrinkTheBodyFallsBackToIdentity() throws IOException { + requestHeaders.add("Accept-Encoding", "bloat"); + byte[] body = largeText(); + ResponseRenderer bloating = + new ResponseRenderer( + MAPPERS, THRESHOLD, ContentCodings.of(List.of(), List.of(bloat())).encoders()); + + bloating.render(exchange, Response.bytes(HTTP_OK, body, JSON)); + + assertThat(responseHeaders.getFirst(CONTENT_ENCODING)).isNull(); + assertThat(sink.toByteArray()).isEqualTo(body); + } + + private static ResponseRenderer withDeflate() { + return new ResponseRenderer( + MAPPERS, THRESHOLD, ContentCodings.of(List.of(), List.of(deflate())).encoders()); + } + + /** A coding that doubles every byte, so it can never shrink a body. */ + private static ContentCoding bloat() { + return new ContentCoding() { + @Override + public String token() { + return "bloat"; + } + + @Override + public InputStream decode(InputStream coded) { + return coded; + } + + @Override + public OutputStream encode(OutputStream sink) { + return new FilterOutputStream(sink) { + @Override + public void write(int b) throws IOException { + out.write(b); + out.write(b); + } + }; + } + }; + } + + private static byte[] inflate(byte[] data) throws IOException { + try (InflaterInputStream in = new InflaterInputStream(new ByteArrayInputStream(data))) { + return in.readAllBytes(); + } + } + + private void acceptsGzip() { + requestHeaders.add("Accept-Encoding", "gzip, deflate, br"); + } + + private static byte[] largeText() { + return ("{\"blob\":\"" + "abcdefgh".repeat(300) + "\"}").getBytes(UTF_8); + } + + private static byte[] gunzip(byte[] data) throws IOException { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(data))) { + return in.readAllBytes(); + } + } +} diff --git a/src/test/java/com/retailsvc/http/support/TestCodings.java b/src/test/java/com/retailsvc/http/support/TestCodings.java new file mode 100644 index 0000000..ca1a245 --- /dev/null +++ b/src/test/java/com/retailsvc/http/support/TestCodings.java @@ -0,0 +1,60 @@ +package com.retailsvc.http.support; + +import com.retailsvc.http.ContentCoding; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Set; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; + +/** Content codings for tests, built on java.util.zip so no test needs a compression library. */ +public final class TestCodings { + + private TestCodings() {} + + /** The zlib {@code deflate} coding: a real, dependency-free coding to register from tests. */ + public static ContentCoding deflate() { + return new ContentCoding() { + @Override + public String token() { + return "deflate"; + } + + @Override + public InputStream decode(InputStream coded) { + return new InflaterInputStream(coded); + } + + @Override + public OutputStream encode(OutputStream sink) { + return new DeflaterOutputStream(sink); + } + }; + } + + /** A coding that only carries its names; bodies pass through untouched. */ + public static ContentCoding named(String token, String... aliases) { + Set aliasSet = Set.of(aliases); + return new ContentCoding() { + @Override + public String token() { + return token; + } + + @Override + public Set aliases() { + return aliasSet; + } + + @Override + public InputStream decode(InputStream coded) { + return coded; + } + + @Override + public OutputStream encode(OutputStream sink) { + return sink; + } + }; + } +}