From 9ffbdfe6df994af5cf4a6e07b6c7b04798ab3683 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 08:30:54 +0200 Subject: [PATCH 01/17] docs: Design for non-JSON request bodies (form-urlencoded + text/plain) --- ...26-05-13-non-json-request-bodies-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-non-json-request-bodies-design.md diff --git a/docs/superpowers/specs/2026-05-13-non-json-request-bodies-design.md b/docs/superpowers/specs/2026-05-13-non-json-request-bodies-design.md new file mode 100644 index 0000000..9cb52c9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-non-json-request-bodies-design.md @@ -0,0 +1,188 @@ +# Non-JSON request bodies (Wave 2) + +**Date:** 2026-05-13 +**Status:** Design — ready for implementation plan +**Wave/item:** Wave 2 (new), orig #15 — partial. Slice A only: `application/x-www-form-urlencoded` and `text/plain`. Multipart deferred. + +## Problem + +`RequestPreparationFilter.validateAndParseBody` calls `jsonMapper.mapFrom(body)` unconditionally, regardless of the wire `Content-Type`. Operations whose `requestBody.content` declares `application/x-www-form-urlencoded` or `text/plain` cannot be served — even when the spec is well-formed — because the JSON mapper is applied to non-JSON bytes. + +## Goals + +1. Accept `application/x-www-form-urlencoded` and `text/plain` request bodies when the operation's spec declares them, in addition to the existing `application/json` path. +2. Parse form bodies into a shape the existing validator can consume against an `ObjectSchema` (single values as `String`, repeated keys as `List`). +3. Coerce form-field string values to the property's declared type (number / integer / boolean / arrays of those), matching how query and path parameters are already coerced at the parameter boundary. +4. Hoist the existing `coerceParameterValue` helper into a shared `internal/ValueCoercion` utility, since form-body coercion is the same logic. +5. Keep `RequestPreparationFilter` focused — extract parsing into purpose-specific classes rather than growing the filter. + +## Non-goals + +- `multipart/form-data` (Slice B/C — deferred to a follow-up; needs boundary parsing, per-part headers, file-upload concerns, `encoding` object handling). +- Coercion for `text/plain`. Bodies declared as `text/plain` are passed through as `String`; the schema is expected to be `type: string` (optionally with `format` / `pattern`). Non-string schemas against a `text/plain` body produce the existing strict 400. +- Multi-value semantics for form bodies beyond OpenAPI explode=true repeated-key behaviour. `style: pipeDelimited` / `spaceDelimited` are out of scope (those are Wave 2's parameter-style items). +- Changing JSON body handling. JSON bodies remain strict (no coercion), per #48 / #49. + +## Design + +### Wire behaviour + +| Wire Content-Type | Internal representation | Validation mode | +| --------------------------------------- | -------------------------------------------------------------------- | --------------- | +| `application/json` (existing) | whatever `JsonMapper` returns | strict, no coercion | +| `application/x-www-form-urlencoded` | `Map` — `String` per field, `List` if repeated | coerce per property schema | +| `text/plain` | `String` (UTF-8 or charset from `Content-Type`) | strict (`type: string` expected) | + +Content-Type selection: +1. Read the `Content-Type` request header. Strip media-type parameters (`; charset=...; boundary=...`) — the subtype is what matches the spec's `requestBody.content` key. +2. Look up the spec's `MediaType` by the bare subtype string (`application/x-www-form-urlencoded`, `text/plain`, `application/json`, …). +3. If the spec does not declare that subtype → existing 400 `ValidationError` with keyword `content-type` (unchanged). +4. Empty body + `requestBody.required: true` → existing 400 (unchanged). +5. Empty body + non-required → null (unchanged). + +### Form-urlencoded parser + +New internal class `FormUrlEncodedParser`: + +- Input: `byte[]` body, charset from `Content-Type` `charset=` parameter (default UTF-8). +- Decode bytes to `String` with the resolved charset. +- Split on `&`. For each pair: + - If `=` is absent → `{ key → "" }`. + - Otherwise split on the first `=`. Empty-value entries (`"key="`) → `{ key → "" }`. +- URL-decode both key and value with `URLDecoder.decode(s, charset)`. `+` is mapped to space (standard `URLDecoder` behaviour). +- Output: `Map` backed by `LinkedHashMap` (preserves insertion order). + - First occurrence of a key: store the `String`. + - Second occurrence: replace with `new ArrayList<>(List.of(prevString, newString))`. + - Subsequent: append to the existing `List`. + +Coercion is applied after parsing, before validation: + +- If the body schema is an `ObjectSchema`, walk the parsed map. For each entry `(key, value)`: + - Look up the property schema by `key`. If absent → leave the entry unchanged (validation handles `additionalProperties`). + - If the property schema is `IntegerSchema` / `NumberSchema` / `BooleanSchema` and the value is a single `String` → call `ValueCoercion.coerce(string, schema, "/" + key)`. + - If the property schema is `ArraySchema` with primitive `items` → for each `String` element in the `List`, call `ValueCoercion.coerce(...)`; replace the list contents. The pointer is `"/" + key + "/" + index`. + - Otherwise leave the value as-is. +- Coercion failures throw `ValidationException` with the JSON-pointer set to the failing property. +- If the body schema is not an `ObjectSchema` (rare for form bodies) → no coercion; the validator decides what to do with the raw `Map`. + +### Text/plain parser + +New internal class `TextPlainParser`: + +- Input: `byte[]` body, charset from `Content-Type` `charset=` parameter (default UTF-8). +- Output: the decoded `String`. No transformation. +- Validation runs against the declared schema as-is. The expected schema is `type: string`; anything else surfaces the existing strict type error. + +### Shared helpers + +- `internal/ContentTypeHeader` — static helpers: + - `subtype(String header)` → bare media-type (`"text/plain"` from `"text/plain; charset=utf-8"`). `null` → `"application/json"` (current default). + - `parameter(String header, String name)` → `Optional` for a named parameter, with quoted values unquoted (`charset="utf-8"` → `"utf-8"`). +- `internal/ValueCoercion` — hoisted from `RequestPreparationFilter.coerceParameterValue`. Signature: `Object coerce(String raw, Schema schema, String pointer)`. Same semantics as today; no new behaviour. The filter's private method is deleted and the call site updated. + +### Dispatch refactor inside `RequestPreparationFilter` + +`validateAndParseBody` is refactored to: + +1. Resolve the spec's `MediaType` for the request (existing logic). +2. Read the wire content type via `ContentTypeHeader.subtype(...)`. +3. Dispatch to a parser by subtype. Each parser has slightly different inputs (form needs the body schema for coercion; text needs only the charset header; JSON delegates to the user-supplied mapper), so the dispatch is a plain `switch` rather than a uniform `BodyParser` interface: + +```java +Object parsed = switch (subtype) { + case "application/x-www-form-urlencoded" -> formParser.parseAndCoerce(body, header, mt.schema()); + case "text/plain" -> textParser.parse(body, header); + default -> jsonMapper.mapFrom(body); // application/json +}; +validator.validate(parsed, mt.schema(), ""); +return parsed; +``` + +- `formParser` and `textParser` are stateless instances constructed once in the filter constructor. +- The `default` branch preserves today's exact behaviour for `application/json` (and any spec-declared JSON-ish subtypes that fall through to the user `JsonMapper`). + +### File layout + +**Create:** +- `src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java` +- `src/main/java/com/retailsvc/http/internal/TextPlainParser.java` +- `src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java` +- `src/main/java/com/retailsvc/http/internal/ValueCoercion.java` + +**Modify:** +- `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` — `validateAndParseBody` dispatch refactor; remove private `coerceParameterValue`; route remaining call to `ValueCoercion.coerce`. + +### Error handling + +- Content-Type the spec does not declare → existing `content-type` 400 (unchanged). +- Form body cannot be decoded with the requested charset → `ValidationException` with pointer `/body` and keyword `decode`. Message includes the charset. +- Form-field coercion failure → `ValidationException` with pointer `/` (or `//` for arrays). +- Text/plain decode failure → `ValidationException` `/body` `decode`. +- All `ValidationException`s flow through the existing 400 RFC-7807 path. + +## Testing + +### Unit tests + +- `internal/FormUrlEncodedParserTest.java` + - Empty body → empty map. + - Single field → `{ a: "1" }`. + - Repeated key (`a=1&a=2`) → `{ a: ["1", "2"] }`. + - Empty value (`a=`) → `{ a: "" }`. + - Key without `=` (`a`) → `{ a: "" }`. + - Percent-decoding for both key and value (`a%20b=c%26d` → `{ "a b": "c&d" }`). + - Plus-as-space (`a=b+c` → `{ a: "b c" }`). + - Charset from header (`charset=iso-8859-1`) — decode a non-UTF-8 byte sequence and assert string equality. + - Coercion: integer / number / boolean fields parsed per object schema property types. + - Coercion: array property with integer items → `List`. + - Coercion failure: `"x=abc"` against `type: integer` → `ValidationException` at pointer `/x`. + +- `internal/TextPlainParserTest.java` + - UTF-8 body decoded round-trip. + - Explicit charset from header. + - Empty body → empty string. + +- `internal/ContentTypeHeaderTest.java` + - `subtype("application/json")` → `"application/json"`. + - `subtype("text/plain; charset=utf-8")` → `"text/plain"`. + - `subtype(null)` → `"application/json"`. + - `parameter("text/plain; charset=iso-8859-1", "charset")` → `Optional.of("iso-8859-1")`. + - Quoted parameter value (`charset="utf-8"`) → unquoted. + - Missing parameter → `Optional.empty()`. + - Parameter name match is case-insensitive (`CHARSET=utf-8` → found). + +- `internal/ValueCoercionTest.java` + - Hoisted helper covered directly: integer, number, boolean, default (string) happy paths. + - Failure cases (`"abc"` → integer/number → throws with `type` keyword and pointer). + +### Integration tests + +`src/test/java/com/retailsvc/http/NonJsonBodyIT.java`: + +- POST `application/x-www-form-urlencoded` with `name=foo&age=30` against a new spec operation whose body schema is `{ type: object, properties: { name: { type: string }, age: { type: integer } } }` → handler receives `Map` with `name="foo"` and `age=30L`. +- POST same operation with repeated key for an array property (`tags=a&tags=b`) → handler sees `List` of strings (or coerced types per items schema). +- POST with `age=abc` → 400 RFC-7807 with `/age` pointer and `type` keyword. +- POST `text/plain` body `"hello"` against schema `{ type: string }` → handler receives `"hello"`. +- POST `text/plain` body against schema `{ type: integer }` → 400 (regression check on strict text/plain). +- Spec declaring only `application/json`, wire sends `application/x-www-form-urlencoded` → existing "unsupported content type" 400 (regression). + +### Test fixtures + +- Extend `src/test/resources/openapi.json` with at least two new operations: + - `POST /form-echo` — accepts `application/x-www-form-urlencoded` with an object schema (string + integer + array-of-string properties). + - `POST /text-echo` — accepts `text/plain` with a `type: string` schema. +- Mirror the additions in `src/test/resources/openapi.yaml` (the two fixtures must describe the same API per the yaml-mirrors-json convention). +- Add two handler classes under `src/test/java/com/retailsvc/http/start/` to echo the parsed body, used by `NonJsonBodyIT`. + +## Documentation + +`README.md`: + +- Add a short subsection under the existing Usage section documenting that form-urlencoded and text/plain bodies are supported when declared in the spec, that form fields are coerced to property types, and that text/plain bodies are kept as `String`. +- Note that JSON bodies remain strict (no coercion). + +## Out of scope + +- `multipart/form-data` and OpenAPI `encoding`. Tracked as Slice B/C of orig #15. +- New body parsers exposed as public API. The new classes live in `internal/`; callers do not need to wire anything beyond their existing `JsonMapper`. +- Streaming form/text parsing. Bodies are read into a `byte[]` as today. From 2f96ae22ac1a3b19e6891045c4e1d665797a9d7c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 08:35:28 +0200 Subject: [PATCH 02/17] docs: Implementation plan for non-JSON request bodies --- .../2026-05-13-non-json-request-bodies.md | 1214 +++++++++++++++++ 1 file changed, 1214 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-non-json-request-bodies.md diff --git a/docs/superpowers/plans/2026-05-13-non-json-request-bodies.md b/docs/superpowers/plans/2026-05-13-non-json-request-bodies.md new file mode 100644 index 0000000..5dface3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-non-json-request-bodies.md @@ -0,0 +1,1214 @@ +# Non-JSON Request Bodies Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Accept `application/x-www-form-urlencoded` and `text/plain` request bodies (Wave 2 / orig #15, Slice A), with type coercion for form-body fields. + +**Architecture:** Inside `RequestPreparationFilter.validateAndParseBody`, dispatch on the request's `Content-Type` subtype. Three branches: JSON (existing `JsonMapper`), form-urlencoded (new built-in parser + coercion), text/plain (new built-in parser). Parsing logic lives in focused new classes under `internal/`; the existing `coerceParameterValue` is hoisted into a shared `ValueCoercion` helper used by both parameter coercion and form-body coercion. + +**Tech Stack:** Java 25, `com.sun.net.httpserver`, JDK stdlib `URLDecoder` / `Charset`. JUnit 5 + AssertJ + Mockito. + +**Spec:** `docs/superpowers/specs/2026-05-13-non-json-request-bodies-design.md` + +--- + +## File Structure + +**Create:** +- `src/main/java/com/retailsvc/http/internal/ValueCoercion.java` — hoisted helper. Coerce a single string to integer / number / boolean per `Schema`. Public static `coerce(String raw, Schema schema, String pointer)`. +- `src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java` — parse `Content-Type` header: `subtype(header)` returns bare media-type; `parameter(header, name)` returns named parameter (charset, etc.). +- `src/main/java/com/retailsvc/http/internal/TextPlainParser.java` — decode `byte[]` to `String` using charset from header (default UTF-8). +- `src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java` — parse `byte[]` to `Map` (string or list-of-strings per repeated key), then coerce field values against an `ObjectSchema`. +- `src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java` +- `src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java` +- `src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java` +- `src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java` +- `src/test/java/com/retailsvc/http/start/FormEchoHandler.java` — test handler that echoes the parsed form body as JSON. +- `src/test/java/com/retailsvc/http/start/TextEchoHandler.java` — test handler that echoes the parsed text body. +- `src/test/java/com/retailsvc/http/NonJsonBodyIT.java` + +**Modify:** +- `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` — replace inline `coerceParameterValue` with `ValueCoercion.coerce`; refactor `validateAndParseBody` to dispatch by content-type subtype; instantiate `FormUrlEncodedParser` / `TextPlainParser` in the constructor. +- `src/test/resources/openapi.json` — add `POST /form-echo` and `POST /text-echo` operations. +- `src/test/resources/openapi.yaml` — mirror the JSON additions (yaml-mirrors-json convention). +- `README.md` — short subsection on supported request body content types. + +--- + +## Task 1: Hoist `ValueCoercion` + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/ValueCoercion.java` +- Create: `src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java` +- Modify: `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` + +- [ ] **Step 1: Write failing tests** + +Create `src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java`: + +```java +package com.retailsvc.http.internal; + +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.retailsvc.http.ValidationException; +import com.retailsvc.http.spec.schema.BooleanSchema; +import com.retailsvc.http.spec.schema.IntegerSchema; +import com.retailsvc.http.spec.schema.NumberSchema; +import com.retailsvc.http.spec.schema.Schema; +import com.retailsvc.http.spec.schema.StringSchema; +import com.retailsvc.http.spec.schema.TypeName; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ValueCoercionTest { + + private final Schema intSchema = + new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, null, null, Map.of()); + private final Schema numSchema = + new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, null, null, Map.of()); + private final Schema boolSchema = new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()); + private final Schema strSchema = + new StringSchema( + Set.of(TypeName.STRING), null, null, null, null, null, null, null, null, Map.of()); + + @Test + void coercesIntegerString() { + assertThat(ValueCoercion.coerce("42", intSchema, "/a")).isEqualTo(42L); + } + + @Test + void coercesNumberString() { + assertThat(ValueCoercion.coerce("3.14", numSchema, "/a")).isEqualTo(3.14); + } + + @Test + void coercesBooleanTrue() { + assertThat(ValueCoercion.coerce("true", boolSchema, "/a")).isEqualTo(Boolean.TRUE); + } + + @Test + void coercesBooleanFalse() { + assertThat(ValueCoercion.coerce("false", boolSchema, "/a")).isEqualTo(Boolean.FALSE); + } + + @Test + void leavesStringSchemaUntouched() { + assertThat(ValueCoercion.coerce("hello", strSchema, "/a")).isEqualTo("hello"); + } + + @Test + void integerCoercionFailureThrowsValidationException() { + assertThatThrownBy(() -> ValueCoercion.coerce("abc", intSchema, "/a")) + .isInstanceOf(ValidationException.class) + .extracting("error.pointer", "error.keyword") + .containsExactly("/a", "type"); + assertThat(HTTP_BAD_REQUEST).isEqualTo(400); // sanity: ValidationException is mapped to 400 + } + + @Test + void numberCoercionFailureThrowsValidationException() { + assertThatThrownBy(() -> ValueCoercion.coerce("not-a-number", numSchema, "/x")) + .isInstanceOf(ValidationException.class); + } + + @Test + void booleanCoercionFailureThrowsValidationException() { + assertThatThrownBy(() -> ValueCoercion.coerce("yes", boolSchema, "/b")) + .isInstanceOf(ValidationException.class); + } +} +``` + +> If the constructor signatures for `IntegerSchema` / `NumberSchema` / `StringSchema` / `BooleanSchema` differ from what's shown, read the actual record headers in `src/main/java/com/retailsvc/http/spec/schema/` and adjust the fixtures. The intent is "default-valued schema of the given type" — copy the minimum form used elsewhere in tests (`grep -rn 'new IntegerSchema' src/test`). + +- [ ] **Step 2: Run tests, expect compile failure** + +Run: `mvn -q test -Dtest=ValueCoercionTest` +Expected: `cannot find symbol: ValueCoercion`. + +- [ ] **Step 3: Create `ValueCoercion`** + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.ValidationException; +import com.retailsvc.http.spec.schema.BooleanSchema; +import com.retailsvc.http.spec.schema.IntegerSchema; +import com.retailsvc.http.spec.schema.NumberSchema; +import com.retailsvc.http.spec.schema.Schema; +import com.retailsvc.http.validate.ValidationError; + +/** Coerces wire-format strings (parameters, form-field values) to the target schema type. */ +public final class ValueCoercion { + + private ValueCoercion() {} + + public static Object coerce(String raw, Schema schema, String pointer) { + return switch (schema) { + case IntegerSchema _ -> { + try { + yield Long.parseLong(raw); + } catch (NumberFormatException _) { + throw new ValidationException( + new ValidationError(pointer, "type", "expected integer", raw)); + } + } + case NumberSchema _ -> { + try { + yield Double.parseDouble(raw); + } catch (NumberFormatException _) { + throw new ValidationException( + new ValidationError(pointer, "type", "expected number", raw)); + } + } + case BooleanSchema _ -> { + if ("true".equals(raw)) { + yield Boolean.TRUE; + } + if ("false".equals(raw)) { + yield Boolean.FALSE; + } + throw new ValidationException( + new ValidationError(pointer, "type", "expected boolean", raw)); + } + default -> raw; + }; + } +} +``` + +- [ ] **Step 4: Run tests, expect pass** + +Run: `mvn -q test -Dtest=ValueCoercionTest` +Expected: 8 tests pass. + +- [ ] **Step 5: Replace inline helper in `RequestPreparationFilter`** + +In `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java`: + +1. Delete the private static method `coerceParameterValue(String raw, Schema schema, String pointer)` (currently around line 141–171). +2. Replace its single call site (around line 131) so: +```java +validator.validate(coerceParameterValue(value, p.schema(), pointer), p.schema(), pointer); +``` +becomes: +```java +validator.validate(ValueCoercion.coerce(value, p.schema(), pointer), p.schema(), pointer); +``` +3. Remove now-unused imports if any (`IntegerSchema`, `NumberSchema`, `BooleanSchema` — check whether they're still referenced anywhere in the file). + +- [ ] **Step 6: Run full unit suite** + +Run: `mvn -q test` +Expected: all existing tests still pass (the hoist is a no-op). + +- [ ] **Step 7: Commit** + +``` +git add src/main/java/com/retailsvc/http/internal/ValueCoercion.java src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +git commit -m "refactor: Hoist parameter coercion into ValueCoercion helper" +``` + +--- + +## Task 2: `ContentTypeHeader` helper + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java` +- Create: `src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java` + +- [ ] **Step 1: Write failing tests** + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ContentTypeHeaderTest { + + @Test + void subtypeReturnsBareMediaType() { + assertThat(ContentTypeHeader.subtype("application/json")).isEqualTo("application/json"); + } + + @Test + void subtypeStripsParameters() { + assertThat(ContentTypeHeader.subtype("text/plain; charset=utf-8")).isEqualTo("text/plain"); + } + + @Test + void subtypeTrimsWhitespace() { + assertThat(ContentTypeHeader.subtype(" application/json ")).isEqualTo("application/json"); + } + + @Test + void subtypeDefaultsToApplicationJsonWhenNull() { + assertThat(ContentTypeHeader.subtype(null)).isEqualTo("application/json"); + } + + @Test + void parameterReturnsValue() { + assertThat(ContentTypeHeader.parameter("text/plain; charset=iso-8859-1", "charset")) + .contains("iso-8859-1"); + } + + @Test + void parameterUnquotesValue() { + assertThat(ContentTypeHeader.parameter("text/plain; charset=\"utf-8\"", "charset")) + .contains("utf-8"); + } + + @Test + void parameterReturnsEmptyWhenMissing() { + assertThat(ContentTypeHeader.parameter("text/plain", "charset")).isEmpty(); + } + + @Test + void parameterNameMatchIsCaseInsensitive() { + assertThat(ContentTypeHeader.parameter("text/plain; CHARSET=utf-8", "charset")) + .contains("utf-8"); + } + + @Test + void parameterReturnsEmptyForNullHeader() { + assertThat(ContentTypeHeader.parameter(null, "charset")).isEmpty(); + } +} +``` + +- [ ] **Step 2: Verify failure** + +Run: `mvn -q test -Dtest=ContentTypeHeaderTest` +Expected: `cannot find symbol: ContentTypeHeader`. + +- [ ] **Step 3: Create the helper** + +```java +package com.retailsvc.http.internal; + +import java.util.Locale; +import java.util.Optional; + +/** Parses {@code Content-Type} header values. */ +public final class ContentTypeHeader { + + private ContentTypeHeader() {} + + /** Returns the bare media type, stripping parameters. {@code null} → {@code application/json}. */ + public static String subtype(String header) { + if (header == null) { + return "application/json"; + } + int semi = header.indexOf(';'); + String bare = (semi < 0 ? header : header.substring(0, semi)); + return bare.trim(); + } + + /** Returns the named parameter value (e.g. {@code charset}), or empty if absent. */ + public static Optional parameter(String header, String name) { + if (header == null) { + return Optional.empty(); + } + String target = name.toLowerCase(Locale.ROOT); + int semi = header.indexOf(';'); + if (semi < 0) { + return Optional.empty(); + } + String[] parts = header.substring(semi + 1).split(";"); + for (String p : parts) { + String trimmed = p.trim(); + int eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + String key = trimmed.substring(0, eq).trim().toLowerCase(Locale.ROOT); + if (!key.equals(target)) { + continue; + } + String value = trimmed.substring(eq + 1).trim(); + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + return Optional.of(value); + } + return Optional.empty(); + } +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `mvn -q test -Dtest=ContentTypeHeaderTest` +Expected: 9 tests pass. + +- [ ] **Step 5: Commit** + +``` +git add src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java +git commit -m "feat: Add ContentTypeHeader helper for parsing media-type headers" +``` + +--- + +## Task 3: `TextPlainParser` + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/TextPlainParser.java` +- Create: `src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java` + +- [ ] **Step 1: Write failing tests** + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class TextPlainParserTest { + + private final TextPlainParser parser = new TextPlainParser(); + + @Test + void decodesUtf8ByDefault() { + String body = "hello världen"; + assertThat(parser.parse(body.getBytes(StandardCharsets.UTF_8), null)).isEqualTo(body); + } + + @Test + void respectsCharsetFromHeader() { + String body = "räksmörgås"; + byte[] bytes = body.getBytes(StandardCharsets.ISO_8859_1); + assertThat(parser.parse(bytes, "text/plain; charset=iso-8859-1")).isEqualTo(body); + } + + @Test + void emptyBodyDecodesToEmptyString() { + assertThat(parser.parse(new byte[0], null)).isEqualTo(""); + } + + @Test + void unknownCharsetFallsBackToUtf8() { + String body = "hello"; + assertThat(parser.parse(body.getBytes(StandardCharsets.UTF_8), "text/plain; charset=bogus")) + .isEqualTo(body); + } +} +``` + +- [ ] **Step 2: Verify failure** + +Run: `mvn -q test -Dtest=TextPlainParserTest` +Expected: `cannot find symbol: TextPlainParser`. + +- [ ] **Step 3: Create the parser** + +```java +package com.retailsvc.http.internal; + +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; + +/** Decodes a text/plain request body using the charset declared on {@code Content-Type}. */ +public final class TextPlainParser { + + public String parse(byte[] body, String contentTypeHeader) { + Charset charset = resolveCharset(contentTypeHeader); + return new String(body, charset); + } + + private static Charset resolveCharset(String header) { + return ContentTypeHeader.parameter(header, "charset") + .map(TextPlainParser::safeCharset) + .orElse(StandardCharsets.UTF_8); + } + + private static Charset safeCharset(String name) { + try { + return Charset.forName(name); + } catch (IllegalCharsetNameException | UnsupportedCharsetException _) { + return StandardCharsets.UTF_8; + } + } +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `mvn -q test -Dtest=TextPlainParserTest` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +``` +git add src/main/java/com/retailsvc/http/internal/TextPlainParser.java src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java +git commit -m "feat: Add TextPlainParser for text/plain request bodies" +``` + +--- + +## Task 4: `FormUrlEncodedParser` — parsing only (no coercion yet) + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java` +- Create: `src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java` + +- [ ] **Step 1: Write failing parse-only tests** + +Create the test class with these methods (we'll add coercion tests in Task 5): + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FormUrlEncodedParserTest { + + private final FormUrlEncodedParser parser = new FormUrlEncodedParser(); + + @Test + void emptyBodyReturnsEmptyMap() { + assertThat(parser.parse(new byte[0], null)).isEmpty(); + } + + @Test + void singleField() { + assertThat(parser.parse("a=1".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a", "1")); + } + + @Test + void multipleFields() { + Map result = parser.parse("a=1&b=2".getBytes(StandardCharsets.UTF_8), null); + assertThat(result).containsExactly(Map.entry("a", "1"), Map.entry("b", "2")); + } + + @Test + void repeatedKeyBecomesList() { + Map result = parser.parse("a=1&a=2".getBytes(StandardCharsets.UTF_8), null); + assertThat(result).containsExactly(Map.entry("a", List.of("1", "2"))); + } + + @Test + void threeRepeatedValues() { + Map result = + parser.parse("x=1&x=2&x=3".getBytes(StandardCharsets.UTF_8), null); + assertThat(result).containsExactly(Map.entry("x", List.of("1", "2", "3"))); + } + + @Test + void emptyValue() { + assertThat(parser.parse("a=".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a", "")); + } + + @Test + void keyWithoutEquals() { + assertThat(parser.parse("a".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a", "")); + } + + @Test + void percentDecodesKeyAndValue() { + assertThat(parser.parse("a%20b=c%26d".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a b", "c&d")); + } + + @Test + void plusIsSpace() { + assertThat(parser.parse("a=b+c".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a", "b c")); + } + + @Test + void charsetFromHeader() { + byte[] iso = "x=räka".getBytes(StandardCharsets.ISO_8859_1); + assertThat(parser.parse(iso, "application/x-www-form-urlencoded; charset=iso-8859-1")) + .containsExactly(Map.entry("x", "räka")); + } +} +``` + +- [ ] **Step 2: Verify failure** + +Run: `mvn -q test -Dtest=FormUrlEncodedParserTest` +Expected: `cannot find symbol: FormUrlEncodedParser`. + +- [ ] **Step 3: Create the parser (no coercion yet)** + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.spec.schema.Schema; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Parses an {@code application/x-www-form-urlencoded} request body. */ +public final class FormUrlEncodedParser { + + /** Parses the body to a {@code Map} ({@code String} or {@code List}). */ + public Map parse(byte[] body, String contentTypeHeader) { + Charset charset = resolveCharset(contentTypeHeader); + if (body.length == 0) { + return new LinkedHashMap<>(); + } + String text = new String(body, charset); + Map out = new LinkedHashMap<>(); + for (String pair : text.split("&")) { + if (pair.isEmpty()) { + continue; + } + int eq = pair.indexOf('='); + String rawKey = eq < 0 ? pair : pair.substring(0, eq); + String rawValue = eq < 0 ? "" : pair.substring(eq + 1); + String key = URLDecoder.decode(rawKey, charset); + String value = URLDecoder.decode(rawValue, charset); + addEntry(out, key, value); + } + return out; + } + + private static void addEntry(Map out, String key, String value) { + Object existing = out.get(key); + if (existing == null) { + out.put(key, value); + return; + } + if (existing instanceof List list) { + @SuppressWarnings("unchecked") + List typed = (List) list; + typed.add(value); + return; + } + List list = new ArrayList<>(); + list.add((String) existing); + list.add(value); + out.put(key, list); + } + + /** Returns the parsed map after coercing field values against the given body schema. */ + public Map parseAndCoerce(byte[] body, String contentTypeHeader, Schema schema) { + // Coercion lives in the next task; for now this delegates to parse() so the + // dispatch refactor in Task 6 can already call this method. + return parse(body, contentTypeHeader); + } + + private static Charset resolveCharset(String header) { + return ContentTypeHeader.parameter(header, "charset") + .map(FormUrlEncodedParser::safeCharset) + .orElse(StandardCharsets.UTF_8); + } + + private static Charset safeCharset(String name) { + try { + return Charset.forName(name); + } catch (IllegalCharsetNameException | UnsupportedCharsetException _) { + return StandardCharsets.UTF_8; + } + } +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `mvn -q test -Dtest=FormUrlEncodedParserTest` +Expected: 10 tests pass. + +- [ ] **Step 5: Commit** + +``` +git add src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java +git commit -m "feat: Add FormUrlEncodedParser (parsing only, no coercion yet)" +``` + +--- + +## Task 5: `FormUrlEncodedParser` — coercion against the body schema + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java` +- Modify: `src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java` + +- [ ] **Step 1: Append failing coercion tests** + +Add to `FormUrlEncodedParserTest`: + +```java + @Test + void coercesIntegerProperty() { + com.retailsvc.http.spec.schema.IntegerSchema intSchema = + new com.retailsvc.http.spec.schema.IntegerSchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.INTEGER), + null, null, null, null, null, null, null, java.util.Map.of()); + com.retailsvc.http.spec.schema.ObjectSchema bodySchema = + new com.retailsvc.http.spec.schema.ObjectSchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.OBJECT), + java.util.Map.of("age", intSchema), + java.util.List.of(), + null, + null, + null, + java.util.Map.of()); + + Map out = + parser.parseAndCoerce("age=30".getBytes(StandardCharsets.UTF_8), null, bodySchema); + + assertThat(out).containsExactly(Map.entry("age", 30L)); + } + + @Test + void coercesArrayOfIntegersProperty() { + com.retailsvc.http.spec.schema.IntegerSchema intItems = + new com.retailsvc.http.spec.schema.IntegerSchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.INTEGER), + null, null, null, null, null, null, null, java.util.Map.of()); + com.retailsvc.http.spec.schema.ArraySchema arrSchema = + new com.retailsvc.http.spec.schema.ArraySchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.ARRAY), + intItems, null, null, false, java.util.Map.of()); + com.retailsvc.http.spec.schema.ObjectSchema bodySchema = + new com.retailsvc.http.spec.schema.ObjectSchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.OBJECT), + java.util.Map.of("ids", arrSchema), + java.util.List.of(), null, null, null, java.util.Map.of()); + + Map out = + parser.parseAndCoerce("ids=1&ids=2".getBytes(StandardCharsets.UTF_8), null, bodySchema); + + assertThat(out).containsExactly(Map.entry("ids", List.of(1L, 2L))); + } + + @Test + void coercionFailureThrowsValidationExceptionAtPropertyPointer() { + com.retailsvc.http.spec.schema.IntegerSchema intSchema = + new com.retailsvc.http.spec.schema.IntegerSchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.INTEGER), + null, null, null, null, null, null, null, java.util.Map.of()); + com.retailsvc.http.spec.schema.ObjectSchema bodySchema = + new com.retailsvc.http.spec.schema.ObjectSchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.OBJECT), + java.util.Map.of("age", intSchema), + java.util.List.of(), null, null, null, java.util.Map.of()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> parser.parseAndCoerce( + "age=abc".getBytes(StandardCharsets.UTF_8), null, bodySchema)) + .isInstanceOf(com.retailsvc.http.ValidationException.class) + .extracting("error.pointer", "error.keyword") + .containsExactly("/age", "type"); + } + + @Test + void unknownPropertyPassesThroughUnchanged() { + com.retailsvc.http.spec.schema.ObjectSchema bodySchema = + new com.retailsvc.http.spec.schema.ObjectSchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.OBJECT), + java.util.Map.of(), + java.util.List.of(), null, null, null, java.util.Map.of()); + + Map out = + parser.parseAndCoerce("anything=v".getBytes(StandardCharsets.UTF_8), null, bodySchema); + + assertThat(out).containsExactly(Map.entry("anything", "v")); + } + + @Test + void nonObjectSchemaReturnsRawMap() { + com.retailsvc.http.spec.schema.StringSchema strSchema = + new com.retailsvc.http.spec.schema.StringSchema( + java.util.Set.of(com.retailsvc.http.spec.schema.TypeName.STRING), + null, null, null, null, null, null, null, null, java.util.Map.of()); + + Map out = + parser.parseAndCoerce("a=1".getBytes(StandardCharsets.UTF_8), null, strSchema); + + assertThat(out).containsExactly(Map.entry("a", "1")); + } +``` + +> Cross-check `ObjectSchema` / `ArraySchema` / `IntegerSchema` / `StringSchema` constructor arity by reading their record headers before running. The constructors used here mirror `src/main/java/com/retailsvc/http/spec/schema/*.java`. If a fixture line fails to compile, adjust to match the actual record signatures. + +- [ ] **Step 2: Verify failure** + +Run: `mvn -q test -Dtest=FormUrlEncodedParserTest` +Expected: failures in the four new tests (coercion not yet wired through `parseAndCoerce`). + +- [ ] **Step 3: Implement coercion in `parseAndCoerce`** + +Replace the existing body of `parseAndCoerce` in `FormUrlEncodedParser.java`: + +```java + public Map parseAndCoerce(byte[] body, String contentTypeHeader, Schema schema) { + Map parsed = parse(body, contentTypeHeader); + if (!(schema instanceof com.retailsvc.http.spec.schema.ObjectSchema obj)) { + return parsed; + } + Map properties = obj.properties(); + for (Map.Entry e : parsed.entrySet()) { + Schema propSchema = properties.get(e.getKey()); + if (propSchema == null) { + continue; + } + String pointer = "/" + e.getKey(); + Object value = e.getValue(); + if (propSchema instanceof com.retailsvc.http.spec.schema.ArraySchema arr + && value instanceof List list) { + List coerced = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + coerced.add(ValueCoercion.coerce((String) list.get(i), arr.items(), pointer + "/" + i)); + } + e.setValue(coerced); + } else if (propSchema instanceof com.retailsvc.http.spec.schema.ArraySchema arr + && value instanceof String s) { + // Single value but schema is array — coerce as a one-element list. + e.setValue(List.of(ValueCoercion.coerce(s, arr.items(), pointer + "/0"))); + } else if (value instanceof String s) { + e.setValue(ValueCoercion.coerce(s, propSchema, pointer)); + } + } + return parsed; + } +``` + +(You can keep the explicit `com.retailsvc.http.spec.schema.*` qualifiers, or add imports — Google Java Format will reorder either way.) + +- [ ] **Step 4: Verify pass** + +Run: `mvn -q test -Dtest=FormUrlEncodedParserTest` +Expected: all 15 tests pass. + +- [ ] **Step 5: Commit** + +``` +git add src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java +git commit -m "feat: Form body field coercion against ObjectSchema property types" +``` + +--- + +## Task 6: Wire dispatch into `RequestPreparationFilter` + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` + +- [ ] **Step 1: Add parsers as fields** + +In `RequestPreparationFilter`, after the existing `private final JsonMapper jsonMapper;` field, add: + +```java + private final FormUrlEncodedParser formParser = new FormUrlEncodedParser(); + private final TextPlainParser textParser = new TextPlainParser(); +``` + +- [ ] **Step 2: Refactor `validateAndParseBody` to dispatch** + +Replace the existing body of `validateAndParseBody` (currently around lines 173–199): + +```java + private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] body) { + Optional rb = op.requestBody(); + if (rb.isEmpty()) { + return null; + } + if (body.length == 0) { + if (rb.get().required()) { + throw new ValidationException( + new ValidationError("/body", "required", "request body is required", null)); + } + return null; + } + String header = exchange.getRequestHeaders().getFirst("Content-Type"); + String subtype = ContentTypeHeader.subtype(header); + MediaType mt = rb.get().content().get(subtype); + if (mt == null) { + throw new ValidationException( + new ValidationError( + "/body", "content-type", "unsupported content type: " + subtype, null)); + } + Object parsed = + switch (subtype) { + case "application/x-www-form-urlencoded" -> + formParser.parseAndCoerce(body, header, mt.schema()); + case "text/plain" -> textParser.parse(body, header); + default -> jsonMapper.mapFrom(body); + }; + validator.validate(parsed, mt.schema(), ""); + return parsed; + } +``` + +- [ ] **Step 3: Run full unit test suite** + +Run: `mvn -q test` +Expected: all tests pass (the existing JSON path is unchanged for any content-type that isn't form or text/plain). + +- [ ] **Step 4: Commit** + +``` +git add src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +git commit -m "feat: Dispatch request body parsing by Content-Type subtype" +``` + +--- + +## Task 7: Test fixtures and echo handlers + +**Files:** +- Modify: `src/test/resources/openapi.json` +- Modify: `src/test/resources/openapi.yaml` +- Create: `src/test/java/com/retailsvc/http/start/FormEchoHandler.java` +- Create: `src/test/java/com/retailsvc/http/start/TextEchoHandler.java` + +- [ ] **Step 1: Inspect existing fixtures** + +Read both `src/test/resources/openapi.json` and `src/test/resources/openapi.yaml`. Note an existing JSON-body operation (e.g. `post-data`) and mirror its style for the new entries. The basePath is `/api/v1` and `paths` keys are written without that prefix. + +- [ ] **Step 2: Append `POST /form-echo` to `openapi.json`** + +Insert (preserving JSON validity — comma-separate from the previous path): + +```json + "/form-echo": { + "post": { + "operationId": "form-echo", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + }, + "responses": { + "200": { "description": "ok" } + } + } + }, + "/text-echo": { + "post": { + "operationId": "text-echo", + "requestBody": { + "required": true, + "content": { + "text/plain": { + "schema": { "type": "string" } + } + } + }, + "responses": { + "200": { "description": "ok" } + } + } + } +``` + +- [ ] **Step 3: Mirror in `openapi.yaml`** + +Add the same two operations to `src/test/resources/openapi.yaml`. Use YAML syntax: + +```yaml + /form-echo: + post: + operationId: form-echo + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: { type: string } + age: { type: integer } + tags: + type: array + items: { type: string } + responses: + "200": { description: ok } + /text-echo: + post: + operationId: text-echo + requestBody: + required: true + content: + text/plain: + schema: { type: string } + responses: + "200": { description: ok } +``` + +- [ ] **Step 4: Create `FormEchoHandler`** + +```java +package com.retailsvc.http.start; + +import com.retailsvc.http.Request; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** Echoes the parsed form body to the response as a simple toString of the Map. */ +public class FormEchoHandler implements HttpHandler { + + @Override + public void handle(HttpExchange exchange) throws IOException { + Object parsed = Request.parsed(); + byte[] body = String.valueOf(parsed).getBytes(StandardCharsets.UTF_8); + try (exchange) { + exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + } +} +``` + +- [ ] **Step 5: Create `TextEchoHandler`** + +```java +package com.retailsvc.http.start; + +import com.retailsvc.http.Request; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** Echoes the parsed text/plain body back to the response. */ +public class TextEchoHandler implements HttpHandler { + + @Override + public void handle(HttpExchange exchange) throws IOException { + String parsed = (String) Request.parsed(); + byte[] body = parsed.getBytes(StandardCharsets.UTF_8); + try (exchange) { + exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + } +} +``` + +- [ ] **Step 6: Run full suite to confirm fixtures still parse** + +Run: `mvn -q test` +Expected: BUILD SUCCESS. The new operations are not yet exercised, but the fixture must still parse cleanly for `ServerBaseTest` to load it. + +- [ ] **Step 7: Commit** + +``` +git add src/test/resources/openapi.json src/test/resources/openapi.yaml src/test/java/com/retailsvc/http/start/FormEchoHandler.java src/test/java/com/retailsvc/http/start/TextEchoHandler.java +git commit -m "test: Fixtures and handlers for form-urlencoded and text/plain bodies" +``` + +--- + +## Task 8: Integration tests + +**Files:** +- Create: `src/test/java/com/retailsvc/http/NonJsonBodyIT.java` + +- [ ] **Step 1: Write failing IT** + +Note: `ServerBaseTest.newRequest(...)` hard-codes `Content-Type: application/json` and `HttpRequest.Builder.header(...)` *adds* (does not replace) headers. To send a non-JSON `Content-Type`, build the request directly with `HttpRequest.newBuilder()` and target `http://localhost:/api/v1/...` explicitly: + +```java +package com.retailsvc.http; + +import static java.net.http.HttpRequest.BodyPublishers.ofString; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.start.FormEchoHandler; +import com.retailsvc.http.start.TextEchoHandler; +import com.sun.net.httpserver.HttpHandler; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class NonJsonBodyIT extends ServerBaseTest { + + @Test + void formUrlEncodedBodyParsedAndCoerced() throws Exception { + Map handlers = Map.of("form-echo", new FormEchoHandler()); + try (var s = newServer(handlers); var client = httpClient()) { + var req = postForm(s, "/form-echo", "name=foo&age=30"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + // FormEchoHandler renders Map#toString — Java prints e.g. {name=foo, age=30} + assertThat(resp.body()).contains("name=foo").contains("age=30"); + } + } + + @Test + void formArrayProperty() throws Exception { + Map handlers = Map.of("form-echo", new FormEchoHandler()); + try (var s = newServer(handlers); var client = httpClient()) { + var req = postForm(s, "/form-echo", "tags=a&tags=b"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).contains("tags=[a, b]"); + } + } + + @Test + void formCoercionFailureReturns400() throws Exception { + Map handlers = Map.of("form-echo", new FormEchoHandler()); + try (var s = newServer(handlers); var client = httpClient()) { + var req = postForm(s, "/form-echo", "age=abc"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(400); + assertThat(resp.body()).contains("/age"); + } + } + + @Test + void textPlainBodyParsedAsString() throws Exception { + Map handlers = Map.of("text-echo", new TextEchoHandler()); + try (var s = newServer(handlers); var client = httpClient()) { + var req = postWithContentType(s, "/text-echo", "hello", "text/plain; charset=utf-8"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).isEqualTo("hello"); + } + } + + @Test + void formBodyAgainstJsonOnlyOperationReturns400() throws Exception { + // post-data operation in openapi.json declares only application/json. + try (var s = newServer(Map.of()); var client = httpClient()) { + var req = postForm(s, "/data", "name=foo"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(400); + assertThat(resp.body()).contains("content-type"); + } + } + + private static HttpRequest postForm(OpenApiServer s, String path, String body) { + return postWithContentType(s, path, body, "application/x-www-form-urlencoded"); + } + + private static HttpRequest postWithContentType( + OpenApiServer s, String path, String body, String contentType) { + return HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/api/v1" + path)) + .header("Content-Type", contentType) + .POST(ofString(body)) + .build(); + } +} +``` + +- [ ] **Step 2: Run the new IT** + +Run: `mvn -q verify -Dit.test=NonJsonBodyIT -DfailIfNoTests=false` +Expected: 5 tests pass. + +- [ ] **Step 3: Full verify (regression)** + +Run: `mvn -q verify` +Expected: all unit + integration tests pass. + +- [ ] **Step 4: Commit** + +``` +git add src/test/java/com/retailsvc/http/NonJsonBodyIT.java +git commit -m "test: Integration coverage for form-urlencoded and text/plain bodies" +``` + +--- + +## Task 9: README update + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Pick the insertion point** + +Read README to find the existing usage / request-body section. The most natural location is right after the basic builder example, before the "Extra (non-OpenAPI) handlers" subsection. + +- [ ] **Step 2: Add a "Request body content types" subsection** + +Insert (use the README's heading level, typically `###`): + +````markdown +### Request body content types + +The server reads `requestBody.content` from the spec and selects a parser by `Content-Type` subtype: + +| Content type | Parser | Coercion | +| ------------------------------------- | ---------------------------------------- | -------- | +| `application/json` | Caller-supplied `JsonMapper` | No — strict against the schema | +| `application/x-www-form-urlencoded` | Built-in. `Map` (string or `List` for repeated keys) | Yes — field values coerced to the property schema type (integer / number / boolean / array of those) | +| `text/plain` | Built-in. Decoded `String` | No — schema should be `type: string` | + +Form-field coercion mirrors the rules already used at the parameter boundary: wire is string-only by definition, so a property typed as `integer` accepts `"42"` and yields `42`. Coercion failures surface as RFC-7807 `400` responses with a JSON-pointer to the failing field. + +Both built-in parsers honour the `charset=` parameter on the `Content-Type` header (default UTF-8). Unknown charsets fall back to UTF-8. +```` + +- [ ] **Step 3: Confirm pre-commit hooks pass on README** + +Run: `pre-commit run --files README.md` +Expected: all hooks pass. + +- [ ] **Step 4: Commit** + +``` +git add README.md +git commit -m "docs: Document form-urlencoded and text/plain request body support" +``` + +--- + +## Task 10: Final verification + +- [ ] **Step 1: Clean build** + +Run: `mvn -q clean verify` +Expected: BUILD SUCCESS. All unit and integration tests pass. + +- [ ] **Step 2: Pre-commit on the tree** + +Run: `pre-commit run --all-files` +Expected: all hooks pass. + +- [ ] **Step 3: Working tree clean** + +Run: `git status` +Expected: `nothing to commit, working tree clean`. From b844dd574d5b2cbd2b7a3623afc083f2cbd7b9a3 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 08:58:51 +0200 Subject: [PATCH 03/17] refactor: Hoist parameter coercion into ValueCoercion helper --- .../internal/RequestPreparationFilter.java | 44 +--------- .../http/internal/ValueCoercion.java | 46 ++++++++++ .../http/internal/ValueCoercionTest.java | 85 +++++++++++++++++++ 3 files changed, 132 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/internal/ValueCoercion.java create mode 100644 src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 55fa164..21e7cdf 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -11,10 +11,6 @@ import com.retailsvc.http.spec.Parameter; import com.retailsvc.http.spec.RequestBody; import com.retailsvc.http.spec.Spec; -import com.retailsvc.http.spec.schema.BooleanSchema; -import com.retailsvc.http.spec.schema.IntegerSchema; -import com.retailsvc.http.spec.schema.NumberSchema; -import com.retailsvc.http.spec.schema.Schema; import com.retailsvc.http.validate.ValidationError; import com.retailsvc.http.validate.Validator; import com.sun.net.httpserver.Filter; @@ -128,48 +124,10 @@ private void validateParameters( } continue; } - validator.validate(coerceParameterValue(value, p.schema(), pointer), p.schema(), pointer); + validator.validate(ValueCoercion.coerce(value, p.schema(), pointer), p.schema(), pointer); } } - /** - * Converts a raw parameter string into a typed value matching the schema's primitive kind, so the - * validator (which is faithful to JSON Schema {@code type} semantics) sees the value the spec - * describes rather than its string serialization. Strings that fail to parse are passed through - * unchanged so the validator surfaces a {@code type} error with the original input. - */ - private static Object coerceParameterValue(String raw, Schema schema, String pointer) { - return switch (schema) { - case IntegerSchema _ -> { - try { - yield Long.parseLong(raw); - } catch (NumberFormatException _) { - throw new ValidationException( - new ValidationError(pointer, "type", "expected integer", raw)); - } - } - case NumberSchema _ -> { - try { - yield Double.parseDouble(raw); - } catch (NumberFormatException _) { - throw new ValidationException( - new ValidationError(pointer, "type", "expected number", raw)); - } - } - case BooleanSchema _ -> { - if ("true".equals(raw)) { - yield Boolean.TRUE; - } - if ("false".equals(raw)) { - yield Boolean.FALSE; - } - throw new ValidationException( - new ValidationError(pointer, "type", "expected boolean", raw)); - } - default -> raw; - }; - } - private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] body) { Optional rb = op.requestBody(); if (rb.isEmpty()) { diff --git a/src/main/java/com/retailsvc/http/internal/ValueCoercion.java b/src/main/java/com/retailsvc/http/internal/ValueCoercion.java new file mode 100644 index 0000000..34852b7 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ValueCoercion.java @@ -0,0 +1,46 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.ValidationException; +import com.retailsvc.http.spec.schema.BooleanSchema; +import com.retailsvc.http.spec.schema.IntegerSchema; +import com.retailsvc.http.spec.schema.NumberSchema; +import com.retailsvc.http.spec.schema.Schema; +import com.retailsvc.http.validate.ValidationError; + +/** Coerces wire-format strings (parameters, form-field values) to the target schema type. */ +public final class ValueCoercion { + + private ValueCoercion() {} + + public static Object coerce(String raw, Schema schema, String pointer) { + return switch (schema) { + case IntegerSchema _ -> { + try { + yield Long.parseLong(raw); + } catch (NumberFormatException _) { + throw new ValidationException( + new ValidationError(pointer, "type", "expected integer", raw)); + } + } + case NumberSchema _ -> { + try { + yield Double.parseDouble(raw); + } catch (NumberFormatException _) { + throw new ValidationException( + new ValidationError(pointer, "type", "expected number", raw)); + } + } + case BooleanSchema _ -> { + if ("true".equals(raw)) { + yield Boolean.TRUE; + } + if ("false".equals(raw)) { + yield Boolean.FALSE; + } + throw new ValidationException( + new ValidationError(pointer, "type", "expected boolean", raw)); + } + default -> raw; + }; + } +} diff --git a/src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java b/src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java new file mode 100644 index 0000000..6fcc87e --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java @@ -0,0 +1,85 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.retailsvc.http.ValidationException; +import com.retailsvc.http.spec.schema.BooleanSchema; +import com.retailsvc.http.spec.schema.IntegerSchema; +import com.retailsvc.http.spec.schema.NumberSchema; +import com.retailsvc.http.spec.schema.Schema; +import com.retailsvc.http.spec.schema.StringSchema; +import com.retailsvc.http.spec.schema.TypeName; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ValueCoercionTest { + + private final Schema intSchema = anIntegerSchema(); + private final Schema numSchema = aNumberSchema(); + private final Schema boolSchema = aBooleanSchema(); + private final Schema strSchema = aStringSchema(); + + @Test + void coercesIntegerString() { + assertThat(ValueCoercion.coerce("42", intSchema, "/a")).isEqualTo(42L); + } + + @Test + void coercesNumberString() { + assertThat(ValueCoercion.coerce("3.14", numSchema, "/a")).isEqualTo(3.14); + } + + @Test + void coercesBooleanTrue() { + assertThat(ValueCoercion.coerce("true", boolSchema, "/a")).isEqualTo(Boolean.TRUE); + } + + @Test + void coercesBooleanFalse() { + assertThat(ValueCoercion.coerce("false", boolSchema, "/a")).isEqualTo(Boolean.FALSE); + } + + @Test + void leavesStringSchemaUntouched() { + assertThat(ValueCoercion.coerce("hello", strSchema, "/a")).isEqualTo("hello"); + } + + @Test + void integerCoercionFailureThrowsValidationException() { + assertThatThrownBy(() -> ValueCoercion.coerce("abc", intSchema, "/a")) + .isInstanceOf(ValidationException.class) + .extracting("error.pointer", "error.keyword") + .containsExactly("/a", "type"); + } + + @Test + void numberCoercionFailureThrowsValidationException() { + assertThatThrownBy(() -> ValueCoercion.coerce("not-a-number", numSchema, "/x")) + .isInstanceOf(ValidationException.class); + } + + @Test + void booleanCoercionFailureThrowsValidationException() { + assertThatThrownBy(() -> ValueCoercion.coerce("yes", boolSchema, "/b")) + .isInstanceOf(ValidationException.class); + } + + private static IntegerSchema anIntegerSchema() { + return new IntegerSchema( + Set.of(TypeName.INTEGER), null, null, null, null, null, null, Map.of()); + } + + private static NumberSchema aNumberSchema() { + return new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, null, Map.of()); + } + + private static BooleanSchema aBooleanSchema() { + return new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()); + } + + private static StringSchema aStringSchema() { + return new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null, Map.of()); + } +} From 4113fb94c2b84781a8275ba15d48697858777987 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 08:59:43 +0200 Subject: [PATCH 04/17] feat: Add ContentTypeHeader helper for parsing media-type headers --- .../http/internal/ContentTypeHeader.java | 50 +++++++++++++++++ .../http/internal/ContentTypeHeaderTest.java | 56 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java create mode 100644 src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java diff --git a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java new file mode 100644 index 0000000..5c4475f --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java @@ -0,0 +1,50 @@ +package com.retailsvc.http.internal; + +import java.util.Locale; +import java.util.Optional; + +/** Parses {@code Content-Type} header values. */ +public final class ContentTypeHeader { + + private ContentTypeHeader() {} + + /** Returns the bare media type, stripping parameters. {@code null} → {@code application/json}. */ + public static String subtype(String header) { + if (header == null) { + return "application/json"; + } + int semi = header.indexOf(';'); + String bare = (semi < 0 ? header : header.substring(0, semi)); + return bare.trim(); + } + + /** Returns the named parameter value (e.g. {@code charset}), or empty if absent. */ + public static Optional parameter(String header, String name) { + if (header == null) { + return Optional.empty(); + } + String target = name.toLowerCase(Locale.ROOT); + int semi = header.indexOf(';'); + if (semi < 0) { + return Optional.empty(); + } + String[] parts = header.substring(semi + 1).split(";"); + for (String p : parts) { + String trimmed = p.trim(); + int eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + String key = trimmed.substring(0, eq).trim().toLowerCase(Locale.ROOT); + if (!key.equals(target)) { + continue; + } + String value = trimmed.substring(eq + 1).trim(); + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + return Optional.of(value); + } + return Optional.empty(); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java b/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java new file mode 100644 index 0000000..509ed50 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java @@ -0,0 +1,56 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ContentTypeHeaderTest { + + @Test + void subtypeReturnsBareMediaType() { + assertThat(ContentTypeHeader.subtype("application/json")).isEqualTo("application/json"); + } + + @Test + void subtypeStripsParameters() { + assertThat(ContentTypeHeader.subtype("text/plain; charset=utf-8")).isEqualTo("text/plain"); + } + + @Test + void subtypeTrimsWhitespace() { + assertThat(ContentTypeHeader.subtype(" application/json ")).isEqualTo("application/json"); + } + + @Test + void subtypeDefaultsToApplicationJsonWhenNull() { + assertThat(ContentTypeHeader.subtype(null)).isEqualTo("application/json"); + } + + @Test + void parameterReturnsValue() { + assertThat(ContentTypeHeader.parameter("text/plain; charset=iso-8859-1", "charset")) + .contains("iso-8859-1"); + } + + @Test + void parameterUnquotesValue() { + assertThat(ContentTypeHeader.parameter("text/plain; charset=\"utf-8\"", "charset")) + .contains("utf-8"); + } + + @Test + void parameterReturnsEmptyWhenMissing() { + assertThat(ContentTypeHeader.parameter("text/plain", "charset")).isEmpty(); + } + + @Test + void parameterNameMatchIsCaseInsensitive() { + assertThat(ContentTypeHeader.parameter("text/plain; CHARSET=utf-8", "charset")) + .contains("utf-8"); + } + + @Test + void parameterReturnsEmptyForNullHeader() { + assertThat(ContentTypeHeader.parameter(null, "charset")).isEmpty(); + } +} From 22160f1e51012d8a0ff19305dd267a45b1fb0d35 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:00:32 +0200 Subject: [PATCH 05/17] feat: Add TextPlainParser for text/plain request bodies --- .../http/internal/TextPlainParser.java | 29 +++++++++++++++ .../http/internal/TextPlainParserTest.java | 36 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/internal/TextPlainParser.java create mode 100644 src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java diff --git a/src/main/java/com/retailsvc/http/internal/TextPlainParser.java b/src/main/java/com/retailsvc/http/internal/TextPlainParser.java new file mode 100644 index 0000000..bcf5b6e --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/TextPlainParser.java @@ -0,0 +1,29 @@ +package com.retailsvc.http.internal; + +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; + +/** Decodes a text/plain request body using the charset declared on {@code Content-Type}. */ +public final class TextPlainParser { + + public String parse(byte[] body, String contentTypeHeader) { + Charset charset = resolveCharset(contentTypeHeader); + return new String(body, charset); + } + + private static Charset resolveCharset(String header) { + return ContentTypeHeader.parameter(header, "charset") + .map(TextPlainParser::safeCharset) + .orElse(StandardCharsets.UTF_8); + } + + private static Charset safeCharset(String name) { + try { + return Charset.forName(name); + } catch (IllegalCharsetNameException | UnsupportedCharsetException _) { + return StandardCharsets.UTF_8; + } + } +} diff --git a/src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java b/src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java new file mode 100644 index 0000000..a2260ec --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java @@ -0,0 +1,36 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class TextPlainParserTest { + + private final TextPlainParser parser = new TextPlainParser(); + + @Test + void decodesUtf8ByDefault() { + String body = "hello världen"; + assertThat(parser.parse(body.getBytes(StandardCharsets.UTF_8), null)).isEqualTo(body); + } + + @Test + void respectsCharsetFromHeader() { + String body = "räksmörgås"; + byte[] bytes = body.getBytes(StandardCharsets.ISO_8859_1); + assertThat(parser.parse(bytes, "text/plain; charset=iso-8859-1")).isEqualTo(body); + } + + @Test + void emptyBodyDecodesToEmptyString() { + assertThat(parser.parse(new byte[0], null)).isEqualTo(""); + } + + @Test + void unknownCharsetFallsBackToUtf8() { + String body = "hello"; + assertThat(parser.parse(body.getBytes(StandardCharsets.UTF_8), "text/plain; charset=bogus")) + .isEqualTo(body); + } +} From 5c496bd4d92f415988b956daa85bbc732d348ff6 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:01:47 +0200 Subject: [PATCH 06/17] feat: Add FormUrlEncodedParser (parsing only, no coercion yet) --- .../http/internal/FormUrlEncodedParser.java | 78 +++++++++++++++++++ .../internal/FormUrlEncodedParserTest.java | 73 +++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java create mode 100644 src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java diff --git a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java new file mode 100644 index 0000000..3fc21c6 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java @@ -0,0 +1,78 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.spec.schema.Schema; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Parses an {@code application/x-www-form-urlencoded} request body. */ +public final class FormUrlEncodedParser { + + /** Parses the body to a {@code Map} ({@code String} or {@code List}). */ + public Map parse(byte[] body, String contentTypeHeader) { + Charset charset = resolveCharset(contentTypeHeader); + if (body.length == 0) { + return new LinkedHashMap<>(); + } + String text = new String(body, charset); + Map out = new LinkedHashMap<>(); + for (String pair : text.split("&")) { + if (pair.isEmpty()) { + continue; + } + int eq = pair.indexOf('='); + String rawKey = eq < 0 ? pair : pair.substring(0, eq); + String rawValue = eq < 0 ? "" : pair.substring(eq + 1); + String key = URLDecoder.decode(rawKey, charset); + String value = URLDecoder.decode(rawValue, charset); + addEntry(out, key, value); + } + return out; + } + + private static void addEntry(Map out, String key, String value) { + Object existing = out.get(key); + if (existing == null) { + out.put(key, value); + return; + } + if (existing instanceof List list) { + @SuppressWarnings("unchecked") + List typed = (List) list; + typed.add(value); + return; + } + List list = new ArrayList<>(); + list.add((String) existing); + list.add(value); + out.put(key, list); + } + + /** + * Returns the parsed map after coercing field values against the given body schema. Coercion is + * added in a subsequent task; for now this delegates to {@link #parse}. + */ + public Map parseAndCoerce(byte[] body, String contentTypeHeader, Schema schema) { + return parse(body, contentTypeHeader); + } + + private static Charset resolveCharset(String header) { + return ContentTypeHeader.parameter(header, "charset") + .map(FormUrlEncodedParser::safeCharset) + .orElse(StandardCharsets.UTF_8); + } + + private static Charset safeCharset(String name) { + try { + return Charset.forName(name); + } catch (IllegalCharsetNameException | UnsupportedCharsetException _) { + return StandardCharsets.UTF_8; + } + } +} diff --git a/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java new file mode 100644 index 0000000..0d2d7bc --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java @@ -0,0 +1,73 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FormUrlEncodedParserTest { + + private final FormUrlEncodedParser parser = new FormUrlEncodedParser(); + + @Test + void emptyBodyReturnsEmptyMap() { + assertThat(parser.parse(new byte[0], null)).isEmpty(); + } + + @Test + void singleField() { + assertThat(parser.parse("a=1".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a", "1")); + } + + @Test + void multipleFields() { + Map result = parser.parse("a=1&b=2".getBytes(StandardCharsets.UTF_8), null); + assertThat(result).containsExactly(Map.entry("a", "1"), Map.entry("b", "2")); + } + + @Test + void repeatedKeyBecomesList() { + Map result = parser.parse("a=1&a=2".getBytes(StandardCharsets.UTF_8), null); + assertThat(result).containsExactly(Map.entry("a", List.of("1", "2"))); + } + + @Test + void threeRepeatedValues() { + Map result = parser.parse("x=1&x=2&x=3".getBytes(StandardCharsets.UTF_8), null); + assertThat(result).containsExactly(Map.entry("x", List.of("1", "2", "3"))); + } + + @Test + void emptyValue() { + assertThat(parser.parse("a=".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a", "")); + } + + @Test + void keyWithoutEquals() { + assertThat(parser.parse("a".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a", "")); + } + + @Test + void percentDecodesKeyAndValue() { + assertThat(parser.parse("a%20b=c%26d".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a b", "c&d")); + } + + @Test + void plusIsSpace() { + assertThat(parser.parse("a=b+c".getBytes(StandardCharsets.UTF_8), null)) + .containsExactly(Map.entry("a", "b c")); + } + + @Test + void charsetFromHeader() { + byte[] iso = "x=räka".getBytes(StandardCharsets.ISO_8859_1); + assertThat(parser.parse(iso, "application/x-www-form-urlencoded; charset=iso-8859-1")) + .containsExactly(Map.entry("x", "räka")); + } +} From 777d7a59e24f66accf50d3d7f7b931d8621ab97b Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:04:08 +0200 Subject: [PATCH 07/17] feat: Form body field coercion against ObjectSchema property types --- .../http/internal/FormUrlEncodedParser.java | 33 ++++++-- .../internal/FormUrlEncodedParserTest.java | 81 +++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java index 3fc21c6..dae0429 100644 --- a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java +++ b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java @@ -1,5 +1,7 @@ package com.retailsvc.http.internal; +import com.retailsvc.http.spec.schema.ArraySchema; +import com.retailsvc.http.spec.schema.ObjectSchema; import com.retailsvc.http.spec.schema.Schema; import java.net.URLDecoder; import java.nio.charset.Charset; @@ -54,12 +56,33 @@ private static void addEntry(Map out, String key, String value) out.put(key, list); } - /** - * Returns the parsed map after coercing field values against the given body schema. Coercion is - * added in a subsequent task; for now this delegates to {@link #parse}. - */ + /** Returns the parsed map after coercing field values against the given body schema. */ public Map parseAndCoerce(byte[] body, String contentTypeHeader, Schema schema) { - return parse(body, contentTypeHeader); + Map parsed = parse(body, contentTypeHeader); + if (!(schema instanceof ObjectSchema obj)) { + return parsed; + } + Map properties = obj.properties(); + for (Map.Entry e : parsed.entrySet()) { + Schema propSchema = properties.get(e.getKey()); + if (propSchema == null) { + continue; + } + String pointer = "/" + e.getKey(); + Object value = e.getValue(); + if (propSchema instanceof ArraySchema arr && value instanceof List list) { + List coerced = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + coerced.add(ValueCoercion.coerce((String) list.get(i), arr.items(), pointer + "/" + i)); + } + e.setValue(coerced); + } else if (propSchema instanceof ArraySchema arr && value instanceof String s) { + e.setValue(List.of(ValueCoercion.coerce(s, arr.items(), pointer + "/0"))); + } else if (value instanceof String s) { + e.setValue(ValueCoercion.coerce(s, propSchema, pointer)); + } + } + return parsed; } private static Charset resolveCharset(String header) { diff --git a/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java index 0d2d7bc..5db5173 100644 --- a/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java +++ b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java @@ -2,9 +2,16 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.retailsvc.http.spec.schema.ArraySchema; +import com.retailsvc.http.spec.schema.IntegerSchema; +import com.retailsvc.http.spec.schema.ObjectSchema; +import com.retailsvc.http.spec.schema.Schema; +import com.retailsvc.http.spec.schema.StringSchema; +import com.retailsvc.http.spec.schema.TypeName; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; class FormUrlEncodedParserTest { @@ -70,4 +77,78 @@ void charsetFromHeader() { assertThat(parser.parse(iso, "application/x-www-form-urlencoded; charset=iso-8859-1")) .containsExactly(Map.entry("x", "räka")); } + + @Test + void coercesIntegerProperty() { + IntegerSchema intSchema = anIntegerSchema(); + ObjectSchema bodySchema = anObjectSchema(Map.of("age", intSchema)); + + Map out = + parser.parseAndCoerce("age=30".getBytes(StandardCharsets.UTF_8), null, bodySchema); + + assertThat(out).containsExactly(Map.entry("age", 30L)); + } + + @Test + void coercesArrayOfIntegersProperty() { + IntegerSchema intItems = anIntegerSchema(); + ArraySchema arrSchema = anArraySchemaOf(intItems); + ObjectSchema bodySchema = anObjectSchema(Map.of("ids", arrSchema)); + + Map out = + parser.parseAndCoerce("ids=1&ids=2".getBytes(StandardCharsets.UTF_8), null, bodySchema); + + assertThat(out).containsExactly(Map.entry("ids", List.of(1L, 2L))); + } + + @Test + void coercionFailureThrowsValidationExceptionAtPropertyPointer() { + IntegerSchema intSchema = anIntegerSchema(); + ObjectSchema bodySchema = anObjectSchema(Map.of("age", intSchema)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + parser.parseAndCoerce("age=abc".getBytes(StandardCharsets.UTF_8), null, bodySchema)) + .isInstanceOf(com.retailsvc.http.ValidationException.class) + .extracting("error.pointer", "error.keyword") + .containsExactly("/age", "type"); + } + + @Test + void unknownPropertyPassesThroughUnchanged() { + ObjectSchema bodySchema = anObjectSchema(Map.of()); + + Map out = + parser.parseAndCoerce("anything=v".getBytes(StandardCharsets.UTF_8), null, bodySchema); + + assertThat(out).containsExactly(Map.entry("anything", "v")); + } + + @Test + void nonObjectSchemaReturnsRawMap() { + StringSchema strSchema = aStringSchema(); + + Map out = + parser.parseAndCoerce("a=1".getBytes(StandardCharsets.UTF_8), null, strSchema); + + assertThat(out).containsExactly(Map.entry("a", "1")); + } + + private static IntegerSchema anIntegerSchema() { + return new IntegerSchema( + Set.of(TypeName.INTEGER), null, null, null, null, null, null, Map.of()); + } + + private static StringSchema aStringSchema() { + return new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null, Map.of()); + } + + private static ArraySchema anArraySchemaOf(Schema items) { + return new ArraySchema(Set.of(TypeName.ARRAY), items, null, null, false, Map.of()); + } + + private static ObjectSchema anObjectSchema(Map properties) { + return new ObjectSchema( + Set.of(TypeName.OBJECT), properties, List.of(), null, null, null, Map.of()); + } } From d3261156f7f56b6c15c973679f8ce0a10284626d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:05:15 +0200 Subject: [PATCH 08/17] feat: Dispatch request body parsing by Content-Type subtype --- .../internal/RequestPreparationFilter.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 21e7cdf..4f54a9a 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -27,6 +27,8 @@ public final class RequestPreparationFilter extends Filter { private final Router router; private final Validator validator; private final JsonMapper jsonMapper; + private final FormUrlEncodedParser formParser = new FormUrlEncodedParser(); + private final TextPlainParser textParser = new TextPlainParser(); public RequestPreparationFilter( Spec spec, Router router, Validator validator, JsonMapper jsonMapper) { @@ -140,18 +142,21 @@ private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] } return null; } - String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); - if (contentType == null) { - contentType = "application/json"; - } - contentType = contentType.split(";", 2)[0].trim(); - MediaType mt = rb.get().content().get(contentType); + String header = exchange.getRequestHeaders().getFirst("Content-Type"); + String subtype = ContentTypeHeader.subtype(header); + MediaType mt = rb.get().content().get(subtype); if (mt == null) { throw new ValidationException( new ValidationError( - "/body", "content-type", "unsupported content type: " + contentType, null)); + "/body", "content-type", "unsupported content type: " + subtype, null)); } - Object parsed = jsonMapper.mapFrom(body); + Object parsed = + switch (subtype) { + case "application/x-www-form-urlencoded" -> + formParser.parseAndCoerce(body, header, mt.schema()); + case "text/plain" -> textParser.parse(body, header); + default -> jsonMapper.mapFrom(body); + }; validator.validate(parsed, mt.schema(), ""); return parsed; } From 96e86844234dcd60cb7c3cfc80e8caf1904acfa6 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:07:01 +0200 Subject: [PATCH 09/17] test: Fixtures and handlers for form-urlencoded and text/plain bodies --- .../retailsvc/http/start/FormEchoHandler.java | 24 +++++++++++ .../retailsvc/http/start/TextEchoHandler.java | 24 +++++++++++ src/test/resources/openapi.json | 42 +++++++++++++++++++ src/test/resources/openapi.yaml | 27 ++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/start/FormEchoHandler.java create mode 100644 src/test/java/com/retailsvc/http/start/TextEchoHandler.java diff --git a/src/test/java/com/retailsvc/http/start/FormEchoHandler.java b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java new file mode 100644 index 0000000..24249af --- /dev/null +++ b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java @@ -0,0 +1,24 @@ +package com.retailsvc.http.start; + +import static java.net.HttpURLConnection.HTTP_OK; + +import com.retailsvc.http.Request; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** Echoes the parsed form body to the response as Map#toString. */ +public class FormEchoHandler implements HttpHandler { + + @Override + public void handle(HttpExchange exchange) throws IOException { + Object parsed = Request.parsed(); + byte[] body = String.valueOf(parsed).getBytes(StandardCharsets.UTF_8); + try (exchange) { + exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(HTTP_OK, body.length); + exchange.getResponseBody().write(body); + } + } +} diff --git a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java new file mode 100644 index 0000000..8c69826 --- /dev/null +++ b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java @@ -0,0 +1,24 @@ +package com.retailsvc.http.start; + +import static java.net.HttpURLConnection.HTTP_OK; + +import com.retailsvc.http.Request; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** Echoes the parsed text/plain body back to the response. */ +public class TextEchoHandler implements HttpHandler { + + @Override + public void handle(HttpExchange exchange) throws IOException { + String parsed = (String) Request.parsed(); + byte[] body = parsed.getBytes(StandardCharsets.UTF_8); + try (exchange) { + exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(HTTP_OK, body.length); + exchange.getResponseBody().write(body); + } + } +} diff --git a/src/test/resources/openapi.json b/src/test/resources/openapi.json index 111314f..026e9dd 100644 --- a/src/test/resources/openapi.json +++ b/src/test/resources/openapi.json @@ -335,6 +335,48 @@ }, "responses": { "200": { "description": "OK" } } } + }, + "/form-echo": { + "post": { + "operationId": "form-echo", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + }, + "responses": { + "200": { "description": "ok" } + } + } + }, + "/text-echo": { + "post": { + "operationId": "text-echo", + "requestBody": { + "required": true, + "content": { + "text/plain": { + "schema": { "type": "string" } + } + } + }, + "responses": { + "200": { "description": "ok" } + } + } } }, "components": { diff --git a/src/test/resources/openapi.yaml b/src/test/resources/openapi.yaml index f9eeaf4..35e3988 100644 --- a/src/test/resources/openapi.yaml +++ b/src/test/resources/openapi.yaml @@ -237,6 +237,33 @@ paths: responses: "200": description: OK + /form-echo: + post: + operationId: form-echo + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: { type: string } + age: { type: integer } + tags: + type: array + items: { type: string } + responses: + "200": { description: ok } + /text-echo: + post: + operationId: text-echo + requestBody: + required: true + content: + text/plain: + schema: { type: string } + responses: + "200": { description: ok } components: parameters: From 4db40120e8366c09a2f0fe73b2d11b238d1ef0ea Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:08:30 +0200 Subject: [PATCH 10/17] test: Integration coverage for form-urlencoded and text/plain bodies --- .../com/retailsvc/http/NonJsonBodyIT.java | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/NonJsonBodyIT.java diff --git a/src/test/java/com/retailsvc/http/NonJsonBodyIT.java b/src/test/java/com/retailsvc/http/NonJsonBodyIT.java new file mode 100644 index 0000000..5d40d2a --- /dev/null +++ b/src/test/java/com/retailsvc/http/NonJsonBodyIT.java @@ -0,0 +1,88 @@ +package com.retailsvc.http; + +import static java.net.http.HttpRequest.BodyPublishers.ofString; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.start.FormEchoHandler; +import com.retailsvc.http.start.TextEchoHandler; +import com.sun.net.httpserver.HttpHandler; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class NonJsonBodyIT extends ServerBaseTest { + + @Test + void formUrlEncodedBodyParsedAndCoerced() throws Exception { + Map handlers = Map.of("form-echo", new FormEchoHandler()); + try (var s = newServer(handlers); + var client = httpClient()) { + var req = postForm(s, "/form-echo", "name=foo&age=30"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).contains("name=foo").contains("age=30"); + } + } + + @Test + void formArrayProperty() throws Exception { + Map handlers = Map.of("form-echo", new FormEchoHandler()); + try (var s = newServer(handlers); + var client = httpClient()) { + var req = postForm(s, "/form-echo", "tags=a&tags=b"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).contains("tags=[a, b]"); + } + } + + @Test + void formCoercionFailureReturns400() throws Exception { + Map handlers = Map.of("form-echo", new FormEchoHandler()); + try (var s = newServer(handlers); + var client = httpClient()) { + var req = postForm(s, "/form-echo", "age=abc"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(400); + assertThat(resp.body()).contains("/age"); + } + } + + @Test + void textPlainBodyParsedAsString() throws Exception { + Map handlers = Map.of("text-echo", new TextEchoHandler()); + try (var s = newServer(handlers); + var client = httpClient()) { + var req = postWithContentType(s, "/text-echo", "hello", "text/plain; charset=utf-8"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).isEqualTo("hello"); + } + } + + @Test + void formBodyAgainstJsonOnlyOperationReturns400() throws Exception { + try (var s = newServer(Map.of()); + var client = httpClient()) { + var req = postForm(s, "/data", "name=foo"); + var resp = client.send(req, BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(400); + assertThat(resp.body()).contains("content-type"); + } + } + + private static HttpRequest postForm(OpenApiServer s, String path, String body) { + return postWithContentType(s, path, body, "application/x-www-form-urlencoded"); + } + + private static HttpRequest postWithContentType( + OpenApiServer s, String path, String body, String contentType) { + return HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/api/v1" + path)) + .header("Content-Type", contentType) + .POST(ofString(body)) + .build(); + } +} From 04d4b68004cc34623eaadb2b6947f353dfab0124 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:09:08 +0200 Subject: [PATCH 11/17] docs: Document form-urlencoded and text/plain request body support --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 7cd3875..0f65712 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,20 @@ Map raw = new Yaml().load(Files.newInputStream(Path.of("openapi. ``` The rest is identical. +### Request body content types + +The server reads `requestBody.content` from the spec and selects a parser by `Content-Type` subtype: + +| Content type | Parser | Coercion | +| ------------------------------------- | ---------------------------------------------------------------------------- | -------- | +| `application/json` | Caller-supplied `JsonMapper` | No — strict against the schema | +| `application/x-www-form-urlencoded` | Built-in. `Map` (string or `List` for repeated keys) | Yes — field values coerced to the property schema type (integer / number / boolean / array of those) | +| `text/plain` | Built-in. Decoded `String` | No — schema should be `type: string` | + +Form-field coercion mirrors the rules already used at the parameter boundary: the wire is string-only by definition, so a property typed as `integer` accepts `"42"` and yields `42`. Coercion failures surface as RFC-7807 `400` responses with a JSON-pointer to the failing field. + +Both built-in parsers honour the `charset=` parameter on the `Content-Type` header (default UTF-8). Unknown charsets fall back to UTF-8. + ### Extra (non-OpenAPI) handlers Mount handlers at arbitrary paths outside the OpenAPI spec — useful for liveness probes, From 2acc5db8c82673d29c33ed991cc267a1bdb4cf0d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:36:10 +0200 Subject: [PATCH 12/17] fix: Catch URLDecoder exceptions Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../http/internal/FormUrlEncodedParser.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java index dae0429..7a337c6 100644 --- a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java +++ b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java @@ -31,13 +31,21 @@ public Map parse(byte[] body, String contentTypeHeader) { int eq = pair.indexOf('='); String rawKey = eq < 0 ? pair : pair.substring(0, eq); String rawValue = eq < 0 ? "" : pair.substring(eq + 1); - String key = URLDecoder.decode(rawKey, charset); - String value = URLDecoder.decode(rawValue, charset); + String key = decodeComponent(rawKey, charset); + String value = decodeComponent(rawValue, charset); addEntry(out, key, value); } return out; } + private static String decodeComponent(String value, Charset charset) { + try { + return URLDecoder.decode(value, charset); + } catch (IllegalArgumentException ex) { + throw new ValidationException("/body", "decode", "Malformed form URL encoding", ex); + } + } + private static void addEntry(Map out, String key, String value) { Object existing = out.get(key); if (existing == null) { From 04c50439f3d76d4c0ff2e206e0d4b6af1746d047 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:37:51 +0200 Subject: [PATCH 13/17] fix: Reject NaN and Infinity as Number coercion results Double.parseDouble accepts "NaN", "Infinity", and "-Infinity", which are not valid JSON numbers. They would slip past NumberSchema coercion and bypass numeric constraint validation (any comparison with NaN is false). Reject non-finite results with the same type error used for other coercion failures. --- .../http/internal/ValueCoercion.java | 8 ++++++- .../http/internal/ValueCoercionTest.java | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/internal/ValueCoercion.java b/src/main/java/com/retailsvc/http/internal/ValueCoercion.java index 34852b7..1b98491 100644 --- a/src/main/java/com/retailsvc/http/internal/ValueCoercion.java +++ b/src/main/java/com/retailsvc/http/internal/ValueCoercion.java @@ -23,12 +23,18 @@ public static Object coerce(String raw, Schema schema, String pointer) { } } case NumberSchema _ -> { + double d; try { - yield Double.parseDouble(raw); + d = Double.parseDouble(raw); } catch (NumberFormatException _) { throw new ValidationException( new ValidationError(pointer, "type", "expected number", raw)); } + if (!Double.isFinite(d)) { + throw new ValidationException( + new ValidationError(pointer, "type", "expected number", raw)); + } + yield d; } case BooleanSchema _ -> { if ("true".equals(raw)) { diff --git a/src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java b/src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java index 6fcc87e..0784978 100644 --- a/src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java +++ b/src/test/java/com/retailsvc/http/internal/ValueCoercionTest.java @@ -60,6 +60,30 @@ void numberCoercionFailureThrowsValidationException() { .isInstanceOf(ValidationException.class); } + @Test + void rejectsNaNAsNumber() { + assertThatThrownBy(() -> ValueCoercion.coerce("NaN", numSchema, "/x")) + .isInstanceOf(ValidationException.class) + .extracting("error.pointer", "error.keyword") + .containsExactly("/x", "type"); + } + + @Test + void rejectsPositiveInfinityAsNumber() { + assertThatThrownBy(() -> ValueCoercion.coerce("Infinity", numSchema, "/x")) + .isInstanceOf(ValidationException.class) + .extracting("error.pointer", "error.keyword") + .containsExactly("/x", "type"); + } + + @Test + void rejectsNegativeInfinityAsNumber() { + assertThatThrownBy(() -> ValueCoercion.coerce("-Infinity", numSchema, "/x")) + .isInstanceOf(ValidationException.class) + .extracting("error.pointer", "error.keyword") + .containsExactly("/x", "type"); + } + @Test void booleanCoercionFailureThrowsValidationException() { assertThatThrownBy(() -> ValueCoercion.coerce("yes", boolSchema, "/b")) From 59eede4578508f88d316faa575b809882bc01965 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:41:26 +0200 Subject: [PATCH 14/17] fix: Case-insensitive Content-Type matching Per RFC 9110/2045 media types are case-insensitive. Lower-case the result of ContentTypeHeader.subtype() and lower-case spec content-type keys at parse time so headers like "Application/JSON" or "Text/Plain" match requestBody.content entries regardless of casing. Also fixes a compile error in FormUrlEncodedParser.decodeComponent which referenced a non-existent ValidationException constructor; the URLDecoder failure path now wraps the IllegalArgumentException as a proper ValidationError(/body, decode, ...). --- .../com/retailsvc/http/internal/ContentTypeHeader.java | 7 +++++-- .../com/retailsvc/http/internal/FormUrlEncodedParser.java | 6 +++++- src/main/java/com/retailsvc/http/spec/Spec.java | 6 ++++-- .../com/retailsvc/http/internal/ContentTypeHeaderTest.java | 6 ++++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java index 5c4475f..39ec78e 100644 --- a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java +++ b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java @@ -8,14 +8,17 @@ public final class ContentTypeHeader { private ContentTypeHeader() {} - /** Returns the bare media type, stripping parameters. {@code null} → {@code application/json}. */ + /** + * Returns the bare media type, stripping parameters and lower-casing for case-insensitive + * matching (RFC 9110 / 2045). {@code null} → {@code application/json}. + */ public static String subtype(String header) { if (header == null) { return "application/json"; } int semi = header.indexOf(';'); String bare = (semi < 0 ? header : header.substring(0, semi)); - return bare.trim(); + return bare.trim().toLowerCase(Locale.ROOT); } /** Returns the named parameter value (e.g. {@code charset}), or empty if absent. */ diff --git a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java index 7a337c6..4c7251c 100644 --- a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java +++ b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java @@ -1,8 +1,10 @@ package com.retailsvc.http.internal; +import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.schema.ArraySchema; import com.retailsvc.http.spec.schema.ObjectSchema; import com.retailsvc.http.spec.schema.Schema; +import com.retailsvc.http.validate.ValidationError; import java.net.URLDecoder; import java.nio.charset.Charset; import java.nio.charset.IllegalCharsetNameException; @@ -42,7 +44,9 @@ private static String decodeComponent(String value, Charset charset) { try { return URLDecoder.decode(value, charset); } catch (IllegalArgumentException ex) { - throw new ValidationException("/body", "decode", "Malformed form URL encoding", ex); + throw new ValidationException( + new ValidationError( + "/body", "decode", "malformed form URL encoding: " + ex.getMessage(), value)); } } diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index 276cce1..ce6eb20 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -203,7 +203,7 @@ private static RequestBody parseRequestBody(Map raw) { for (var e : contentRaw.entrySet()) { Map mt = (Map) e.getValue(); content.put( - e.getKey(), + e.getKey().toLowerCase(java.util.Locale.ROOT), new MediaType(SchemaParser.parse(mt.getOrDefault(SCHEMA_KEY, Map.of("type", "object"))))); } return new RequestBody(Boolean.TRUE.equals(raw.get("required")), Map.copyOf(content)); @@ -219,7 +219,9 @@ private static Map parseResponses(Map raw) { for (var ce : contentRaw.entrySet()) { Map mt = (Map) ce.getValue(); if (mt.containsKey(SCHEMA_KEY)) { - content.put(ce.getKey(), new MediaType(SchemaParser.parse(mt.get(SCHEMA_KEY)))); + content.put( + ce.getKey().toLowerCase(java.util.Locale.ROOT), + new MediaType(SchemaParser.parse(mt.get(SCHEMA_KEY)))); } } out.put(e.getKey(), new Response(Map.copyOf(content))); diff --git a/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java b/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java index 509ed50..b7e86ab 100644 --- a/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java +++ b/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java @@ -26,6 +26,12 @@ void subtypeDefaultsToApplicationJsonWhenNull() { assertThat(ContentTypeHeader.subtype(null)).isEqualTo("application/json"); } + @Test + void subtypeLowerCasesMediaType() { + assertThat(ContentTypeHeader.subtype("Application/JSON")).isEqualTo("application/json"); + assertThat(ContentTypeHeader.subtype("Text/Plain; charset=UTF-8")).isEqualTo("text/plain"); + } + @Test void parameterReturnsValue() { assertThat(ContentTypeHeader.parameter("text/plain; charset=iso-8859-1", "charset")) From 76049c877a9324814a84b2bf84a3ea1dc69a4a98 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:45:05 +0200 Subject: [PATCH 15/17] refactor: Address SonarQube findings on body parsing helpers - ContentTypeHeader.parameter: replace dual-continue loop with a single early-return guarded by an inline key check; extract quote-stripping into a small unquote() helper. - FormUrlEncodedParser.decodeComponent: chain the original IllegalArgumentException via initCause() on the rethrown ValidationException so the underlying cause is preserved. - FormUrlEncodedParser.addEntry: replace get()+null-check+put() with Map.merge(), which is the right primitive for collision handling. - TextPlainParserTest: use isEmpty() instead of isEqualTo(""). --- .../http/internal/ContentTypeHeader.java | 24 ++++++------ .../http/internal/FormUrlEncodedParser.java | 39 ++++++++++--------- .../http/internal/TextPlainParserTest.java | 2 +- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java index 39ec78e..bf8fe75 100644 --- a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java +++ b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java @@ -31,23 +31,21 @@ public static Optional parameter(String header, String name) { if (semi < 0) { return Optional.empty(); } - String[] parts = header.substring(semi + 1).split(";"); - for (String p : parts) { + for (String p : header.substring(semi + 1).split(";")) { String trimmed = p.trim(); int eq = trimmed.indexOf('='); - if (eq <= 0) { - continue; + String key = (eq <= 0) ? "" : trimmed.substring(0, eq).trim().toLowerCase(Locale.ROOT); + if (key.equals(target)) { + return Optional.of(unquote(trimmed.substring(eq + 1).trim())); } - String key = trimmed.substring(0, eq).trim().toLowerCase(Locale.ROOT); - if (!key.equals(target)) { - continue; - } - String value = trimmed.substring(eq + 1).trim(); - if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { - value = value.substring(1, value.length() - 1); - } - return Optional.of(value); } return Optional.empty(); } + + private static String unquote(String value) { + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + return value.substring(1, value.length() - 1); + } + return value; + } } diff --git a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java index 4c7251c..1bb65a0 100644 --- a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java +++ b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java @@ -44,28 +44,31 @@ private static String decodeComponent(String value, Charset charset) { try { return URLDecoder.decode(value, charset); } catch (IllegalArgumentException ex) { - throw new ValidationException( - new ValidationError( - "/body", "decode", "malformed form URL encoding: " + ex.getMessage(), value)); + ValidationException ve = + new ValidationException( + new ValidationError( + "/body", "decode", "malformed form URL encoding: " + ex.getMessage(), value)); + ve.initCause(ex); + throw ve; } } private static void addEntry(Map out, String key, String value) { - Object existing = out.get(key); - if (existing == null) { - out.put(key, value); - return; - } - if (existing instanceof List list) { - @SuppressWarnings("unchecked") - List typed = (List) list; - typed.add(value); - return; - } - List list = new ArrayList<>(); - list.add((String) existing); - list.add(value); - out.put(key, list); + out.merge( + key, + value, + (existing, incoming) -> { + if (existing instanceof List list) { + @SuppressWarnings("unchecked") + List typed = (List) list; + typed.add((String) incoming); + return typed; + } + List merged = new ArrayList<>(); + merged.add((String) existing); + merged.add((String) incoming); + return merged; + }); } /** Returns the parsed map after coercing field values against the given body schema. */ diff --git a/src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java b/src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java index a2260ec..892e088 100644 --- a/src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java +++ b/src/test/java/com/retailsvc/http/internal/TextPlainParserTest.java @@ -24,7 +24,7 @@ void respectsCharsetFromHeader() { @Test void emptyBodyDecodesToEmptyString() { - assertThat(parser.parse(new byte[0], null)).isEqualTo(""); + assertThat(parser.parse(new byte[0], null)).isEmpty(); } @Test From 89cb890c34dcb66cec853ad84f785aa2d9340ba6 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:51:34 +0200 Subject: [PATCH 16/17] refactor: Rename ContentTypeHeader.subtype to mediaType The method returns the full media type (e.g. "application/json"), not the RFC 9110 subtype (e.g. "json"). Rename the method, its single caller's local variable, and the test method names to match what's actually returned. --- .../http/internal/ContentTypeHeader.java | 2 +- .../internal/RequestPreparationFilter.java | 8 +++---- .../http/internal/ContentTypeHeaderTest.java | 22 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java index bf8fe75..d0858ca 100644 --- a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java +++ b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java @@ -12,7 +12,7 @@ private ContentTypeHeader() {} * Returns the bare media type, stripping parameters and lower-casing for case-insensitive * matching (RFC 9110 / 2045). {@code null} → {@code application/json}. */ - public static String subtype(String header) { + public static String mediaType(String header) { if (header == null) { return "application/json"; } diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 4f54a9a..58b5b3a 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -143,15 +143,15 @@ private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] return null; } String header = exchange.getRequestHeaders().getFirst("Content-Type"); - String subtype = ContentTypeHeader.subtype(header); - MediaType mt = rb.get().content().get(subtype); + String mediaType = ContentTypeHeader.mediaType(header); + MediaType mt = rb.get().content().get(mediaType); if (mt == null) { throw new ValidationException( new ValidationError( - "/body", "content-type", "unsupported content type: " + subtype, null)); + "/body", "content-type", "unsupported content type: " + mediaType, null)); } Object parsed = - switch (subtype) { + switch (mediaType) { case "application/x-www-form-urlencoded" -> formParser.parseAndCoerce(body, header, mt.schema()); case "text/plain" -> textParser.parse(body, header); diff --git a/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java b/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java index b7e86ab..4c0d599 100644 --- a/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java +++ b/src/test/java/com/retailsvc/http/internal/ContentTypeHeaderTest.java @@ -7,29 +7,29 @@ class ContentTypeHeaderTest { @Test - void subtypeReturnsBareMediaType() { - assertThat(ContentTypeHeader.subtype("application/json")).isEqualTo("application/json"); + void mediaTypeReturnsBareValue() { + assertThat(ContentTypeHeader.mediaType("application/json")).isEqualTo("application/json"); } @Test - void subtypeStripsParameters() { - assertThat(ContentTypeHeader.subtype("text/plain; charset=utf-8")).isEqualTo("text/plain"); + void mediaTypeStripsParameters() { + assertThat(ContentTypeHeader.mediaType("text/plain; charset=utf-8")).isEqualTo("text/plain"); } @Test - void subtypeTrimsWhitespace() { - assertThat(ContentTypeHeader.subtype(" application/json ")).isEqualTo("application/json"); + void mediaTypeTrimsWhitespace() { + assertThat(ContentTypeHeader.mediaType(" application/json ")).isEqualTo("application/json"); } @Test - void subtypeDefaultsToApplicationJsonWhenNull() { - assertThat(ContentTypeHeader.subtype(null)).isEqualTo("application/json"); + void mediaTypeDefaultsToApplicationJsonWhenNull() { + assertThat(ContentTypeHeader.mediaType(null)).isEqualTo("application/json"); } @Test - void subtypeLowerCasesMediaType() { - assertThat(ContentTypeHeader.subtype("Application/JSON")).isEqualTo("application/json"); - assertThat(ContentTypeHeader.subtype("Text/Plain; charset=UTF-8")).isEqualTo("text/plain"); + void mediaTypeLowerCasesValue() { + assertThat(ContentTypeHeader.mediaType("Application/JSON")).isEqualTo("application/json"); + assertThat(ContentTypeHeader.mediaType("Text/Plain; charset=UTF-8")).isEqualTo("text/plain"); } @Test From 3cfc2b04fa9418b05aa511628d1040489afa3349 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 09:53:59 +0200 Subject: [PATCH 17/17] docs: Fix README inaccuracies on parser selection and form value types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Selection is by the full media type (type/subtype), not the RFC 9110 subtype. Note that lookup is case-insensitive. - After coercion, form list values are not strictly List — an integer-array property yields List, etc. Describe the pre- and post-coercion shape correctly. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f65712..278f1ae 100644 --- a/README.md +++ b/README.md @@ -102,12 +102,12 @@ The rest is identical. ### Request body content types -The server reads `requestBody.content` from the spec and selects a parser by `Content-Type` subtype: +The server reads `requestBody.content` from the spec and selects a parser by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive): | Content type | Parser | Coercion | | ------------------------------------- | ---------------------------------------------------------------------------- | -------- | | `application/json` | Caller-supplied `JsonMapper` | No — strict against the schema | -| `application/x-www-form-urlencoded` | Built-in. `Map` (string or `List` for repeated keys) | Yes — field values coerced to the property schema type (integer / number / boolean / array of those) | +| `application/x-www-form-urlencoded` | Built-in. `Map`. A single value is a `String`; repeated keys produce a `List`. After coercion the element type tracks the schema (e.g. an `integer` array yields `List`). | Yes — field values coerced to the property schema type (integer / number / boolean / array of those) | | `text/plain` | Built-in. Decoded `String` | No — schema should be `type: string` | Form-field coercion mirrors the rules already used at the parameter boundary: the wire is string-only by definition, so a property typed as `integer` accepts `"42"` and yields `42`. Coercion failures surface as RFC-7807 `400` responses with a JSON-pointer to the failing field.