feat(framework): form-encoded body parsing — media_type + form_values

- media_type(req): content-type lowercased, "; charset=..." stripped,
  "" when absent — the content-negotiation hook
- form_values(req): application/x-www-form-urlencoded body -> decoded
  pairs through the existing query decoder ('+' as space, %XX); nil on
  any other content-type so a JSON body is never misread as a form key
- web-app CreateProduct accepts form OR JSON; shared create_product
  insert path; field/number validation answers 400
- probe 7/7 (plus/pct decode, empty value, case + charset param, json
  and missing content-type nil, empty body, media_type strip) + ASan
- gate grows 17 -> 19: form create 201 with decoded name, non-numeric
  price 400; hit() gains a content-type argument
- README: checklist row form ✅ (multipart stays candidate), limits
  paragraph updated; story 16 + board record the landing
- gates: web-app 19/0, oop-e2e 89/0, deps-accept 8/0, log-watcher 7/0,
  employee 8/0, woc-test green

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-20 03:21:48 +02:00
parent e05a27c898
commit a5826495e9
6 changed files with 72 additions and 12 deletions

View file

@ -38,9 +38,13 @@ exactly as `drop_fresh_text` does, pinned by
parsing, pure-`.wo` base64, constant-time `ct_eq`, `req.principal` as the
blessed principal slot (Middleware.before takes `mut req`), `BearerAuth` +
`BasicAuth` middlewares; policy stays app-side. Probe matrix 26/26
ASan-clean; web-app dogfoods BearerAuth; `just web-app` **17/0**. The
framework README carries the core checklist (✅ / candidate / parked-by-
design rows).
ASan-clean; web-app dogfoods BearerAuth. The framework README carries the
core checklist (✅ / candidate / parked-by-design rows).
**Form-encoded bodies landed 2026-08-20** (same branch): `media_type(req)`
+ `form_values(req)` (nil on any other content-type; '+'/%XX decoded);
CreateProduct accepts form OR JSON into one insert path; `just web-app`
**19/0**. Multipart is the candidate next slice.
Next per the implementation order (17 parked): finish the half-done
database branches — 9c (ipc-attach: manifest + binding) and 9d

View file

@ -42,15 +42,36 @@ class ShowProduct {
}
}
-- Accepts BOTH bodies: a form post (application/x-www-form-urlencoded,
-- the framework's form_values hook) and JSON — same insert either way.
fn create_product(name: Text, price: Int, stock: Int) -> Resp {
let made = try insert Product { name: name, price: price, stock: stock }
catch (e) nil;
if made == nil { return conflict("product name already exists"); }
return created_json(view_json(name, price, stock));
}
class CreateProduct {
pad: Int
fn handle(req: Req) -> Resp {
if media_type(req) == "application/x-www-form-urlencoded" {
let f = form_values(req);
if f == nil { return bad_request("unreadable form body"); }
let name = f["name"];
if name == nil { return bad_request("form needs name, price, stock"); }
let ps = f["price"];
if ps == nil { return bad_request("form needs name, price, stock"); }
let ss = f["stock"];
if ss == nil { return bad_request("form needs name, price, stock"); }
let price = parse_int(ps);
if price == nil { return bad_request("price must be a number"); }
let stock = parse_int(ss);
if stock == nil { return bad_request("stock must be a number"); }
return create_product(name, price, stock);
}
let v = json.decode(req.body) as ProductView;
if v == nil { return bad_request("body must be {name, price, stock}"); }
let made = try insert Product { name: v.name, price: v.price, stock: v.stock }
catch (e) nil;
if made == nil { return conflict("product name already exists"); }
return created_json(view_json(v.name, v.price, v.stock));
return create_product(v.name, v.price, v.stock);
}
}

View file

@ -56,7 +56,10 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
TLS+ALPN and gives browsers HTTP/2 while this backend speaks HTTP/1.1
keep-alive. See the web-app sample's README for the nginx sketch.
- `Content-Length` bodies only (no chunked encoding), no WebSockets/SSE,
JSON-first (no templates).
JSON-first (no templates). Form-encoded bodies parse through
`form_values(req)` (`+` and `%XX` decoded, nil on any other
content-type); `media_type(req)` names the body's media type for
content negotiation. Multipart: not yet.
## The core checklist (what a framework core owes, and where this one is)
@ -68,7 +71,8 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
| Request/response types | ✅ `Req`/`Resp` + builders + `set_header` |
| Bearer/Basic auth mechanism + principal | ✅ `http/auth.wo`, `req.principal` |
| Body parsing hooks: JSON | ✅ the language's checked `json.decode` |
| Body parsing hooks: form-encoded, multipart | ⬜ candidate next slices (form first — `parse_query` already decodes the encoding) |
| Body parsing hooks: form-encoded | ✅ `form_values(req)` — nil unless the content-type says form; `media_type(req)` exposed for content negotiation |
| Body parsing hooks: multipart | ⬜ candidate next slice |
| Error handling → status mapping | 🔶 trap = 500, builders per status; a per-error mapping hook is a candidate slice |
| Body streaming, backpressure | ⏸ needs fibers/shards (iterations 8/11) — whole bodies until then, by design |
| Cancellation propagation | ⏸ process-level only (`env.stopping()`); per-request cancel needs fibers (11) |

View file

@ -71,6 +71,25 @@ fn parse_query(qs: Text) -> map<Text, Text> {
return q;
}
-- The request's media type: the content-type header lowercased with any
-- parameters ("; charset=...") stripped; "" when the header is absent.
pub fn media_type(req: Req) -> Text {
let ct = req.headers["content-type"];
if ct == nil { return ""; }
let semi = index_of(ct, ";");
if semi >= 0 { return to_lower(trim(substr(ct, 0, semi))); }
return to_lower(trim("${ct}"));
}
-- Form-encoded body -> decoded pairs ('+' as space, %XX), the body-parsing
-- hook for application/x-www-form-urlencoded. nil when the content-type
-- says the body is something else — a JSON body is not silently misread
-- as one giant form key.
pub fn form_values(req: Req) -> ?map<Text, Text> {
if media_type(req) != "application/x-www-form-urlencoded" { return nil; }
return parse_query(req.body);
}
fn malformed(rest: Text) -> Parsed {
return Parsed { closed: false, ok: false, req: nil, rest: rest };
}

View file

@ -37,6 +37,13 @@
> what parks behind 8/11 by design (streaming, backpressure, per-request
> cancellation).
>
> **Form-encoded bodies LANDED 2026-08-20** (same branch): `media_type(req)`
> (content-type lowercased, parameters stripped) and `form_values(req)`
> (nil unless the content-type says form; '+' and %XX decoded through the
> existing query decoder). Probe 7/7 release + ASan; web-app's
> CreateProduct now accepts form OR JSON into one insert path;
> `just web-app` 19/0. Multipart stays the candidate next slice.
>
> The framework is written IN writeonce and imported
> like any dependency (iteration 15 is the prerequisite). TLS terminates at a
> reverse proxy — browsers get TLS+ALPN+h2 from nginx/caddy while the

View file

@ -55,13 +55,14 @@ SRV=$!
for _ in $(seq 1 40); do grep -q listening "$W/srv.out" 2>/dev/null && break; sleep 0.1; done
# one tiny HTTP client; python is already a repo test dependency
hit() { # method path [body] [auth: yes|no] -> "STATUS|BODY"
python3 - "$PORT" "$1" "$2" "${3:-}" "${4:-yes}" <<'PYEOF'
hit() { # method path [body] [auth: yes|no] [content-type] -> "STATUS|BODY"
python3 - "$PORT" "$1" "$2" "${3:-}" "${4:-yes}" "${5:-}" <<'PYEOF'
import socket, sys
port, method, path, body, auth = int(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
port, method, path, body, auth, ct = int(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6]
s = socket.create_connection(("127.0.0.1", port), timeout=5)
h = f"{method} {path} HTTP/1.1\r\nhost: a\r\n"
if auth == "yes": h += "authorization: Bearer s3cr3t\r\n"
if ct: h += f"content-type: {ct}\r\n"
h += f"content-length: {len(body)}\r\n\r\n{body}"
s.sendall(h.encode())
d = b""
@ -107,6 +108,10 @@ expect "empty list" "$(hit GET /products)" 200 "[]"
expect "create product (201)" "$(hit POST /products '{"name":"mug","price":900,"stock":5}')" 201 '"name":"mug"'
expect "duplicate name is 409 (@unique)" "$(hit POST /products '{"name":"mug","price":1,"stock":1}')" 409
expect "malformed json is 400" "$(hit POST /products '{oops')" 400
expect "form-encoded create (201, + and %XX decoded)" \
"$(hit POST /products 'name=form+kettle&price=1250&stock=2' yes 'application/x-www-form-urlencoded; charset=UTF-8')" 201 '"name":"form kettle"'
expect "form with a non-numeric price is 400" \
"$(hit POST /products 'name=x&price=abc&stock=1' yes 'application/x-www-form-urlencoded')" 400
expect "list shows the product" "$(hit GET /products)" 200 '"price":900'
expect "show by :name capture" "$(hit GET /products/mug)" 200 '"stock":5'
expect "unknown product is 404" "$(hit GET /products/none)" 404