feat(framework): multipart/form-data parsing — Part, multipart_parts, part_named
- http/multipart.wo: RFC 7578 whole-body parsing within BODY_MAX —
boundary from the raw content-type (quoted or bare, key
case-insensitive), parts split on --boundary, each part = headers,
blank line, content; filename + per-part content-type kept (lowercased)
- strict malformed-is-nil: no closing --boundary-- marker, a part
without content-disposition, missing blank line, no boundary param,
wrong media type — all nil, the caller's 400
- part_named(parts, name): first matching field's content, caller-owned
- web-app CreateProduct now accepts multipart/form/JSON (curl -F shape)
into the shared insert path
- probe 13/13 + 3x reuse loop (fields, crlf-in-content, quoted boundary,
file part, zero-part close, five malformed shapes) release + ASan
- gate grows 19 -> 21: multipart create 201, missing closing marker 400
- README: multipart row ✅ (all three body hooks done), limits updated;
story 16 + board record the landing
- gates: web-app 21/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
1995de5c28
commit
f5a92fe536
6 changed files with 149 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"); }
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
103
docs/examples/writeonce-framework/http/multipart.wo
Normal file
103
docs/examples/writeonce-framework/http/multipart.wo
Normal file
|
|
@ -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<headers>\r\n\r\n<content>\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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue