|
| 1 | +# Non-JSON request bodies (Wave 2) |
| 2 | + |
| 3 | +**Date:** 2026-05-13 |
| 4 | +**Status:** Design — ready for implementation plan |
| 5 | +**Wave/item:** Wave 2 (new), orig #15 — partial. Slice A only: `application/x-www-form-urlencoded` and `text/plain`. Multipart deferred. |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +`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. |
| 10 | + |
| 11 | +## Goals |
| 12 | + |
| 13 | +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. |
| 14 | +2. Parse form bodies into a shape the existing validator can consume against an `ObjectSchema` (single values as `String`, repeated keys as `List<String>`). |
| 15 | +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. |
| 16 | +4. Hoist the existing `coerceParameterValue` helper into a shared `internal/ValueCoercion` utility, since form-body coercion is the same logic. |
| 17 | +5. Keep `RequestPreparationFilter` focused — extract parsing into purpose-specific classes rather than growing the filter. |
| 18 | + |
| 19 | +## Non-goals |
| 20 | + |
| 21 | +- `multipart/form-data` (Slice B/C — deferred to a follow-up; needs boundary parsing, per-part headers, file-upload concerns, `encoding` object handling). |
| 22 | +- 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. |
| 23 | +- 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). |
| 24 | +- Changing JSON body handling. JSON bodies remain strict (no coercion), per #48 / #49. |
| 25 | + |
| 26 | +## Design |
| 27 | + |
| 28 | +### Wire behaviour |
| 29 | + |
| 30 | +| Wire Content-Type | Internal representation | Validation mode | |
| 31 | +| --------------------------------------- | -------------------------------------------------------------------- | --------------- | |
| 32 | +| `application/json` (existing) | whatever `JsonMapper` returns | strict, no coercion | |
| 33 | +| `application/x-www-form-urlencoded` | `Map<String, Object>` — `String` per field, `List<String>` if repeated | coerce per property schema | |
| 34 | +| `text/plain` | `String` (UTF-8 or charset from `Content-Type`) | strict (`type: string` expected) | |
| 35 | + |
| 36 | +Content-Type selection: |
| 37 | +1. Read the `Content-Type` request header. Strip media-type parameters (`; charset=...; boundary=...`) — the subtype is what matches the spec's `requestBody.content` key. |
| 38 | +2. Look up the spec's `MediaType` by the bare subtype string (`application/x-www-form-urlencoded`, `text/plain`, `application/json`, …). |
| 39 | +3. If the spec does not declare that subtype → existing 400 `ValidationError` with keyword `content-type` (unchanged). |
| 40 | +4. Empty body + `requestBody.required: true` → existing 400 (unchanged). |
| 41 | +5. Empty body + non-required → null (unchanged). |
| 42 | + |
| 43 | +### Form-urlencoded parser |
| 44 | + |
| 45 | +New internal class `FormUrlEncodedParser`: |
| 46 | + |
| 47 | +- Input: `byte[]` body, charset from `Content-Type` `charset=` parameter (default UTF-8). |
| 48 | +- Decode bytes to `String` with the resolved charset. |
| 49 | +- Split on `&`. For each pair: |
| 50 | + - If `=` is absent → `{ key → "" }`. |
| 51 | + - Otherwise split on the first `=`. Empty-value entries (`"key="`) → `{ key → "" }`. |
| 52 | +- URL-decode both key and value with `URLDecoder.decode(s, charset)`. `+` is mapped to space (standard `URLDecoder` behaviour). |
| 53 | +- Output: `Map<String, Object>` backed by `LinkedHashMap` (preserves insertion order). |
| 54 | + - First occurrence of a key: store the `String`. |
| 55 | + - Second occurrence: replace with `new ArrayList<>(List.of(prevString, newString))`. |
| 56 | + - Subsequent: append to the existing `List<String>`. |
| 57 | + |
| 58 | +Coercion is applied after parsing, before validation: |
| 59 | + |
| 60 | +- If the body schema is an `ObjectSchema`, walk the parsed map. For each entry `(key, value)`: |
| 61 | + - Look up the property schema by `key`. If absent → leave the entry unchanged (validation handles `additionalProperties`). |
| 62 | + - If the property schema is `IntegerSchema` / `NumberSchema` / `BooleanSchema` and the value is a single `String` → call `ValueCoercion.coerce(string, schema, "/" + key)`. |
| 63 | + - If the property schema is `ArraySchema` with primitive `items` → for each `String` element in the `List<String>`, call `ValueCoercion.coerce(...)`; replace the list contents. The pointer is `"/" + key + "/" + index`. |
| 64 | + - Otherwise leave the value as-is. |
| 65 | +- Coercion failures throw `ValidationException` with the JSON-pointer set to the failing property. |
| 66 | +- If the body schema is not an `ObjectSchema` (rare for form bodies) → no coercion; the validator decides what to do with the raw `Map`. |
| 67 | + |
| 68 | +### Text/plain parser |
| 69 | + |
| 70 | +New internal class `TextPlainParser`: |
| 71 | + |
| 72 | +- Input: `byte[]` body, charset from `Content-Type` `charset=` parameter (default UTF-8). |
| 73 | +- Output: the decoded `String`. No transformation. |
| 74 | +- Validation runs against the declared schema as-is. The expected schema is `type: string`; anything else surfaces the existing strict type error. |
| 75 | + |
| 76 | +### Shared helpers |
| 77 | + |
| 78 | +- `internal/ContentTypeHeader` — static helpers: |
| 79 | + - `subtype(String header)` → bare media-type (`"text/plain"` from `"text/plain; charset=utf-8"`). `null` → `"application/json"` (current default). |
| 80 | + - `parameter(String header, String name)` → `Optional<String>` for a named parameter, with quoted values unquoted (`charset="utf-8"` → `"utf-8"`). |
| 81 | +- `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. |
| 82 | + |
| 83 | +### Dispatch refactor inside `RequestPreparationFilter` |
| 84 | + |
| 85 | +`validateAndParseBody` is refactored to: |
| 86 | + |
| 87 | +1. Resolve the spec's `MediaType` for the request (existing logic). |
| 88 | +2. Read the wire content type via `ContentTypeHeader.subtype(...)`. |
| 89 | +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: |
| 90 | + |
| 91 | +```java |
| 92 | +Object parsed = switch (subtype) { |
| 93 | + case "application/x-www-form-urlencoded" -> formParser.parseAndCoerce(body, header, mt.schema()); |
| 94 | + case "text/plain" -> textParser.parse(body, header); |
| 95 | + default -> jsonMapper.mapFrom(body); // application/json |
| 96 | +}; |
| 97 | +validator.validate(parsed, mt.schema(), ""); |
| 98 | +return parsed; |
| 99 | +``` |
| 100 | + |
| 101 | +- `formParser` and `textParser` are stateless instances constructed once in the filter constructor. |
| 102 | +- 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`). |
| 103 | + |
| 104 | +### File layout |
| 105 | + |
| 106 | +**Create:** |
| 107 | +- `src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java` |
| 108 | +- `src/main/java/com/retailsvc/http/internal/TextPlainParser.java` |
| 109 | +- `src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java` |
| 110 | +- `src/main/java/com/retailsvc/http/internal/ValueCoercion.java` |
| 111 | + |
| 112 | +**Modify:** |
| 113 | +- `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` — `validateAndParseBody` dispatch refactor; remove private `coerceParameterValue`; route remaining call to `ValueCoercion.coerce`. |
| 114 | + |
| 115 | +### Error handling |
| 116 | + |
| 117 | +- Content-Type the spec does not declare → existing `content-type` 400 (unchanged). |
| 118 | +- Form body cannot be decoded with the requested charset → `ValidationException` with pointer `/body` and keyword `decode`. Message includes the charset. |
| 119 | +- Form-field coercion failure → `ValidationException` with pointer `/<key>` (or `/<key>/<index>` for arrays). |
| 120 | +- Text/plain decode failure → `ValidationException` `/body` `decode`. |
| 121 | +- All `ValidationException`s flow through the existing 400 RFC-7807 path. |
| 122 | + |
| 123 | +## Testing |
| 124 | + |
| 125 | +### Unit tests |
| 126 | + |
| 127 | +- `internal/FormUrlEncodedParserTest.java` |
| 128 | + - Empty body → empty map. |
| 129 | + - Single field → `{ a: "1" }`. |
| 130 | + - Repeated key (`a=1&a=2`) → `{ a: ["1", "2"] }`. |
| 131 | + - Empty value (`a=`) → `{ a: "" }`. |
| 132 | + - Key without `=` (`a`) → `{ a: "" }`. |
| 133 | + - Percent-decoding for both key and value (`a%20b=c%26d` → `{ "a b": "c&d" }`). |
| 134 | + - Plus-as-space (`a=b+c` → `{ a: "b c" }`). |
| 135 | + - Charset from header (`charset=iso-8859-1`) — decode a non-UTF-8 byte sequence and assert string equality. |
| 136 | + - Coercion: integer / number / boolean fields parsed per object schema property types. |
| 137 | + - Coercion: array property with integer items → `List<Long>`. |
| 138 | + - Coercion failure: `"x=abc"` against `type: integer` → `ValidationException` at pointer `/x`. |
| 139 | + |
| 140 | +- `internal/TextPlainParserTest.java` |
| 141 | + - UTF-8 body decoded round-trip. |
| 142 | + - Explicit charset from header. |
| 143 | + - Empty body → empty string. |
| 144 | + |
| 145 | +- `internal/ContentTypeHeaderTest.java` |
| 146 | + - `subtype("application/json")` → `"application/json"`. |
| 147 | + - `subtype("text/plain; charset=utf-8")` → `"text/plain"`. |
| 148 | + - `subtype(null)` → `"application/json"`. |
| 149 | + - `parameter("text/plain; charset=iso-8859-1", "charset")` → `Optional.of("iso-8859-1")`. |
| 150 | + - Quoted parameter value (`charset="utf-8"`) → unquoted. |
| 151 | + - Missing parameter → `Optional.empty()`. |
| 152 | + - Parameter name match is case-insensitive (`CHARSET=utf-8` → found). |
| 153 | + |
| 154 | +- `internal/ValueCoercionTest.java` |
| 155 | + - Hoisted helper covered directly: integer, number, boolean, default (string) happy paths. |
| 156 | + - Failure cases (`"abc"` → integer/number → throws with `type` keyword and pointer). |
| 157 | + |
| 158 | +### Integration tests |
| 159 | + |
| 160 | +`src/test/java/com/retailsvc/http/NonJsonBodyIT.java`: |
| 161 | + |
| 162 | +- 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`. |
| 163 | +- 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). |
| 164 | +- POST with `age=abc` → 400 RFC-7807 with `/age` pointer and `type` keyword. |
| 165 | +- POST `text/plain` body `"hello"` against schema `{ type: string }` → handler receives `"hello"`. |
| 166 | +- POST `text/plain` body against schema `{ type: integer }` → 400 (regression check on strict text/plain). |
| 167 | +- Spec declaring only `application/json`, wire sends `application/x-www-form-urlencoded` → existing "unsupported content type" 400 (regression). |
| 168 | + |
| 169 | +### Test fixtures |
| 170 | + |
| 171 | +- Extend `src/test/resources/openapi.json` with at least two new operations: |
| 172 | + - `POST /form-echo` — accepts `application/x-www-form-urlencoded` with an object schema (string + integer + array-of-string properties). |
| 173 | + - `POST /text-echo` — accepts `text/plain` with a `type: string` schema. |
| 174 | +- Mirror the additions in `src/test/resources/openapi.yaml` (the two fixtures must describe the same API per the yaml-mirrors-json convention). |
| 175 | +- Add two handler classes under `src/test/java/com/retailsvc/http/start/` to echo the parsed body, used by `NonJsonBodyIT`. |
| 176 | + |
| 177 | +## Documentation |
| 178 | + |
| 179 | +`README.md`: |
| 180 | + |
| 181 | +- 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`. |
| 182 | +- Note that JSON bodies remain strict (no coercion). |
| 183 | + |
| 184 | +## Out of scope |
| 185 | + |
| 186 | +- `multipart/form-data` and OpenAPI `encoding`. Tracked as Slice B/C of orig #15. |
| 187 | +- New body parsers exposed as public API. The new classes live in `internal/`; callers do not need to wire anything beyond their existing `JsonMapper`. |
| 188 | +- Streaming form/text parsing. Bodies are read into a `byte[]` as today. |
0 commit comments