Skip to content

Commit 9ffbdfe

Browse files
committed
docs: Design for non-JSON request bodies (form-urlencoded + text/plain)
1 parent 7447a0e commit 9ffbdfe

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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

Comments
 (0)