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:
parent
e05a27c898
commit
a5826495e9
6 changed files with 72 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue