From a5826495e925bf5b4ed21795dace5f97aec9f4f4 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Thu, 20 Aug 2026 03:21:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(framework):=20form-encoded=20body=20parsin?= =?UTF-8?q?g=20=E2=80=94=20media=5Ftype=20+=20form=5Fvalues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/00-status.md | 10 +++++-- docs/examples/web-app/main.wo | 29 ++++++++++++++++--- docs/examples/writeonce-framework/README.md | 8 +++-- .../writeonce-framework/http/parse.wo | 19 ++++++++++++ .../16-web-framework.md | 7 +++++ scripts/web-app-accept.sh | 11 +++++-- 6 files changed, 72 insertions(+), 12 deletions(-) diff --git a/docs/00-status.md b/docs/00-status.md index 3523fe9..897429b 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -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 diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo index af4adbf..0d67740 100644 --- a/docs/examples/web-app/main.wo +++ b/docs/examples/web-app/main.wo @@ -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); } } diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-framework/README.md index 2dbeaaa..d66928f 100644 --- a/docs/examples/writeonce-framework/README.md +++ b/docs/examples/writeonce-framework/README.md @@ -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) | diff --git a/docs/examples/writeonce-framework/http/parse.wo b/docs/examples/writeonce-framework/http/parse.wo index bc5bdba..3f3d4a6 100644 --- a/docs/examples/writeonce-framework/http/parse.wo +++ b/docs/examples/writeonce-framework/http/parse.wo @@ -71,6 +71,25 @@ fn parse_query(qs: Text) -> map { 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 { + 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 }; } diff --git a/docs/stories/language-runtime-database/16-web-framework.md b/docs/stories/language-runtime-database/16-web-framework.md index 162120b..4fe78f0 100644 --- a/docs/stories/language-runtime-database/16-web-framework.md +++ b/docs/stories/language-runtime-database/16-web-framework.md @@ -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 diff --git a/scripts/web-app-accept.sh b/scripts/web-app-accept.sh index 8b6945d..e929b4c 100755 --- a/scripts/web-app-accept.sh +++ b/scripts/web-app-accept.sh @@ -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