diff --git a/docs/00-status.md b/docs/00-status.md index 897429b..933342d 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -43,8 +43,14 @@ 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. +CreateProduct accepts form OR JSON into one insert path. + +**Multipart landed 2026-08-20** (same branch): `http/multipart.wo` — +RFC 7578 fields + file parts, strict malformed-is-nil, `part_named`; +whole-body within BODY_MAX (streaming parks behind 8/11). CreateProduct +takes multipart/form/JSON; `just web-app` **21/0**. Surfaced + fixed the +RETURN flavor of the interp-of-borrowed-place emitter bug (emit_return now +sees through Interp; same corpus pin). Body-parsing hooks: all three ✅. 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 0d67740..1345953 100644 --- a/docs/examples/web-app/main.wo +++ b/docs/examples/web-app/main.wo @@ -42,8 +42,8 @@ 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. +-- Accepts THREE bodies: multipart/form-data (curl -F), a form post +-- (application/x-www-form-urlencoded), 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; @@ -54,6 +54,21 @@ fn create_product(name: Text, price: Int, stock: Int) -> Resp { class CreateProduct { pad: Int fn handle(req: Req) -> Resp { + if media_type(req) == "multipart/form-data" { + let ps = multipart_parts(req); + if ps == nil { return bad_request("unreadable multipart body"); } + let name = part_named(ps, "name"); + if name == nil { return bad_request("multipart needs name, price, stock"); } + let pstr = part_named(ps, "price"); + if pstr == nil { return bad_request("multipart needs name, price, stock"); } + let sstr = part_named(ps, "stock"); + if sstr == nil { return bad_request("multipart needs name, price, stock"); } + let price = parse_int(pstr); + if price == nil { return bad_request("price must be a number"); } + let stock = parse_int(sstr); + if stock == nil { return bad_request("stock must be a number"); } + return create_product(name, price, stock); + } if media_type(req) == "application/x-www-form-urlencoded" { let f = form_values(req); if f == nil { return bad_request("unreadable form body"); } diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-framework/README.md index d66928f..07e3f26 100644 --- a/docs/examples/writeonce-framework/README.md +++ b/docs/examples/writeonce-framework/README.md @@ -58,8 +58,10 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", - `Content-Length` bodies only (no chunked encoding), no WebSockets/SSE, 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. + content-type); multipart/form-data through `multipart_parts(req)` + (whole-body, bounded by BODY_MAX — no streaming uploads until + fibers/shards) with `part_named` for fields; `media_type(req)` names + the body's media type for content negotiation. ## The core checklist (what a framework core owes, and where this one is) @@ -72,7 +74,7 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", | 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 | ✅ `form_values(req)` — nil unless the content-type says form; `media_type(req)` exposed for content negotiation | -| Body parsing hooks: multipart | ⬜ candidate next slice | +| Body parsing hooks: multipart | ✅ `multipart_parts(req)` (RFC 7578: fields + file parts, filename/mime kept) + `part_named` | | 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/multipart.wo b/docs/examples/writeonce-framework/http/multipart.wo new file mode 100644 index 0000000..457a7e7 --- /dev/null +++ b/docs/examples/writeonce-framework/http/multipart.wo @@ -0,0 +1,103 @@ +-- http/multipart.wo — multipart/form-data parsing (RFC 7578), the body +-- hook for file uploads and curl -F. Whole-body parsing over the buffered +-- body (the serve loop already bounds it at BODY_MAX): parts split on the +-- boundary, each part = headers, blank line, content. Anything malformed +-- answers nil — the caller's 400, never a guess. + +pub typedef Part = { + name: Text, -- content-disposition name (form field) + filename: Text, -- "" unless the part is a file + mime: Text, -- the part's own content-type, lowercased, "" if absent + content: Text -- the raw bytes +} + +-- A quoted parameter value loses its quotes; a bare one is trimmed. +fn unquote(v: Text) -> Text { + let t = trim(v); + if len(t) >= 2 and starts_with(t, "\"") and ends_with(t, "\"") { + return substr(t, 1, len(t) - 2); + } + return t; +} + +-- The boundary parameter from the RAW content-type header (media_type +-- strips parameters, so this reads the header itself). Value may be quoted; +-- the parameter key is case-insensitive, the value is not. +fn boundary_of(req: Req) -> ?Text { + let ct = req.headers["content-type"]; + if ct == nil { return nil; } + for tok in split(ct, ";") { + let t = trim(tok); + if starts_with(to_lower(t), "boundary=") { + let v = unquote(substr(t, 9, len(t) - 9)); + if v != "" { return v; } + } + } + return nil; +} + +-- One piece between boundary markers: "\r\n\r\n\r\n\r\n". +-- nil on any malformation; a part without a content-disposition is +-- malformed (RFC 7578: every part carries one). +fn parse_part(piece: Text) -> ?Part { + if starts_with(piece, "\r\n") == false { return nil; } + let he = index_of(piece, "\r\n\r\n"); + if he < 0 { return nil; } + if he + 6 > len(piece) { return nil; } + if ends_with(piece, "\r\n") == false { return nil; } + let headers = substr(piece, 2, he - 2); + let content = substr(piece, he + 4, len(piece) - he - 6); + let name = ""; + let filename = ""; + let mime = ""; + let disposed = false; + for line in split(headers, "\r\n") { + let low = to_lower(line); + if starts_with(low, "content-disposition:") { + disposed = true; + for tok in split(line, ";") { + let t = trim(tok); + let tl = to_lower(t); + if starts_with(tl, "name=") { name = unquote(substr(t, 5, len(t) - 5)); } + if starts_with(tl, "filename=") { filename = unquote(substr(t, 9, len(t) - 9)); } + } + } + if starts_with(low, "content-type:") { + mime = to_lower(trim(substr(line, 13, len(line) - 13))); + } + } + if disposed == false { return nil; } + return Part { name: name, filename: filename, mime: mime, content: content }; +} + +-- The request's parts, in body order. nil unless the content-type is +-- multipart/form-data with a boundary, every part parses, and the body +-- carries the closing "--boundary--" marker. +pub fn multipart_parts(req: Req) -> ?multi Part { + if media_type(req) != "multipart/form-data" { return nil; } + let b = boundary_of(req); + if b == nil { return nil; } + let pieces = split(req.body, "--${b}"); + if len(pieces) < 2 { return nil; } + let parts: multi Part = []; + let i = 1; + let ended = false; + while i < len(pieces) { + let piece = pieces[i]; + if starts_with(piece, "--") { ended = true; break; } + let p = parse_part(piece); + if p == nil { return nil; } + push(parts, p); + i = i + 1; + } + if ended == false { return nil; } + return parts; +} + +-- The content of the first part with this field name; nil if absent. +pub fn part_named(parts: multi Part, name: Text) -> ?Text { + for p in parts { + if p.name == name { return "${p.content}"; } + } + return nil; +} diff --git a/docs/stories/language-runtime-database/16-web-framework.md b/docs/stories/language-runtime-database/16-web-framework.md index 4fe78f0..1d3ab40 100644 --- a/docs/stories/language-runtime-database/16-web-framework.md +++ b/docs/stories/language-runtime-database/16-web-framework.md @@ -42,7 +42,17 @@ > (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. +> `just web-app` 19/0. +> +> **Multipart LANDED 2026-08-20** (same branch): `http/multipart.wo` — +> RFC 7578 parts (fields + file parts with filename/mime), quoted or bare +> boundary, strict malformed-is-nil (missing closing marker, missing +> content-disposition), `part_named` accessor; whole-body within BODY_MAX, +> streaming parks behind 8/11. Probe 13/13 + reuse loop, release + ASan; +> CreateProduct accepts multipart/form/JSON; `just web-app` 21/0. It +> surfaced the RETURN flavor of the interp-of-borrowed-place emitter bug +> (`return "${p.content}"` — corruption two requests after the handler); +> fixed in emit_return, pinned in the same fixture. > > The framework is written IN writeonce and imported > like any dependency (iteration 15 is the prerequisite). TLS terminates at a diff --git a/scripts/web-app-accept.sh b/scripts/web-app-accept.sh index e929b4c..6914c7c 100755 --- a/scripts/web-app-accept.sh +++ b/scripts/web-app-accept.sh @@ -112,6 +112,11 @@ 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 +MP=$'--BXB\r\ncontent-disposition: form-data; name="name"\r\n\r\nmp teapot\r\n--BXB\r\ncontent-disposition: form-data; name="price"\r\n\r\n700\r\n--BXB\r\ncontent-disposition: form-data; name="stock"\r\n\r\n3\r\n--BXB--\r\n' +expect "multipart create (201, curl -F shape)" \ + "$(hit POST /products "$MP" yes 'multipart/form-data; boundary=BXB')" 201 '"name":"mp teapot"' +expect "multipart without the closing marker is 400" \ + "$(hit POST /products $'--BXB\r\ncontent-disposition: form-data; name="name"\r\n\r\nx\r\n' yes 'multipart/form-data; boundary=BXB')" 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