diff --git a/README.md b/README.md index 71d2f38..abc0129 100644 --- a/README.md +++ b/README.md @@ -679,6 +679,20 @@ var name = m.fields['name'][0]; var file = m.files['avatar'][0]; ``` +`HttpMultipartWriter` builds the same bodies for client uploads: + +```fun +use 'http-multipart.fun'; + +var w = HttpMultipartWriter(); +w.field('name', 'alice'); +w.file('avatar', 'a.txt', data, [contentType: 'text/plain']); +var body = w.body(); +var ct = w.contentType(); +``` + +Pass `body` and `Content-Type: ` to `HttpClient` to send it. + Environment variables are available without platform-specific FFI: ```fun diff --git a/README.zh.md b/README.zh.md index 8f53f26..eae122e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -638,6 +638,20 @@ var name = m.fields['name'][0]; var file = m.files['avatar'][0]; ``` +客户端上传用 `HttpMultipartWriter` 生成同样的 body: + +```fun +use 'http-multipart.fun'; + +var w = HttpMultipartWriter(); +w.field('name', 'alice'); +w.file('avatar', 'a.txt', data, [contentType: 'text/plain']); +var body = w.body(); +var ct = w.contentType(); +``` + +把 `body` 和 `Content-Type: ` 传给 `HttpClient` 即可发送。 + 环境变量也可以直接读取,不需要自己写平台 FFI: ```fun diff --git a/fun/lib/http-multipart.fun b/fun/lib/http-multipart.fun index 54aaf59..8ce0036 100644 --- a/fun/lib/http-multipart.fun +++ b/fun/lib/http-multipart.fun @@ -1,16 +1,25 @@ // Copyright (c) 2010-2026 Zhang Weidong // SPDX-License-Identifier: MIT // -// http-multipart: parse multipart/form-data bodies (RFC 7578 / RFC 2046). +// http-multipart: parse and build multipart/form-data bodies (RFC 7578 / RFC 2046). // +// Parse: // var m = HttpMultipartParse(req.body, req.header('content-type')); // if not m.ok then raise m.error; end if; // var name = m.fields['name'][0]; // var file = m.files['avatar'][0]; // -// The body must already be in memory. Fields are strings; files are part -// objects with filename/contentType/headers/data. Duplicate names stay as -// arrays. Invalid input returns ok:false/error; it does not raise. +// Build: +// var w = HttpMultipartWriter(); +// w.field('user', 'alice'); +// w.file('avatar', 'a.txt', data); +// var body = w.body(); +// var ct = w.contentType(); +// +// Bodies are built in memory. Fields are strings; files are part objects with +// filename/contentType/headers/data. Duplicate names stay as arrays. Invalid +// parse input returns ok:false/error; writer misuse raises. +use 'uuid.fun'; var _MP_CRLF = 13.toChar() & 10.toChar(); var _MP_CRLF2 = _MP_CRLF & _MP_CRLF; @@ -275,3 +284,248 @@ fun HttpMultipartParse(body, ct, opts) end do; result = new [ok: true, error: nil, parts: parts, fields: fields, files: files]; end fun; + +// --- writer --------------------------------------------------------------- + +var _MPW_BCHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' & "'()+_,-./:=? "; +var _MPW_TCHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&' & 39.toChar() & '*+-.^_`|~'; + +fun _mpwString(v, label) + if v.type() <> 8 then raise 'http-multipart: ' & label & ' must be a string'; end if; + result = true; +end fun; + +fun _mpwHeaderSafe(s, label) + if s.subpos(13.toChar()) >= 0 or s.subpos(10.toChar()) >= 0 or s.subpos(0.toChar()) >= 0 then + raise 'http-multipart: ' & label & ' must not contain CR, LF or NUL'; + end if; + result = true; +end fun; + +fun _mpwEscape(s) + var out = new []; + var i = 0; + while i < s.length() do + var c = s[i]; + if c = 92.toChar() or c = 34.toChar() then out.@add(92.toChar()); end if; + out.@add(c); + i += 1; + end do; + result = out.@toJson(-1); +end fun; + +fun _mpwBchar(c) + result = _MPW_BCHARS.subpos(c) >= 0; +end fun; + +fun _mpwBoundaryValid(b) + var n = b.length(); + if n < 1 or n > 70 then result = false; return; end if; + if b[n - 1] = ' ' then result = false; return; end if; + var i = 0; + while i < n do + if not _mpwBchar(b[i]) then result = false; return; end if; + i += 1; + end do; + result = true; +end fun; + +fun _mpwNeedsQuote(b) + var i = 0; + while i < b.length() do + if _MPW_TCHARS.subpos(b[i]) < 0 then result = true; return; end if; + i += 1; + end do; + result = false; +end fun; + +fun _mpwDelimiterAt(s, pos, b) + var d = '--' & b; + if pos + d.length() > s.length() then result = false; return; end if; + if s.substr(pos, d.length()) <> d then result = false; return; end if; + var q = _mpSkipHws(s, pos + d.length()); + // The writer always appends CRLF after data, so a delimiter ending the data + // still becomes a real delimiter in the final body. + if q >= s.length() then result = true; return; end if; + var m = ''; + if q + 2 <= s.length() then m = s.substr(q, 2); end if; + result = m = _MP_CRLF or m = '--'; +end fun; + +fun _mpwDataCollides(data, b) + if _mpwDelimiterAt(data, 0, b) then result = true; return; end if; + var mark = _MP_CRLF & '--' & b; + var base = 0; + var rest = data; + loop + var j = rest.subpos(mark); + if j < 0 then result = false; return; end if; + if _mpwDelimiterAt(data, base + j + _MP_CRLF.length(), b) then result = true; return; end if; + base += j + 1; + rest = data.substr(base); + end loop; +end fun; + +class _MPW(o) + var parts = new []; + var boundaryValue = nil; + var custom = nil; + var finished = false; + var bodyText = ''; + + if o <> nil then + var bad = ''; + var listish = false; + try + o.@each((v, k){ + if k = nil or k = '' then + listish = true; + elsif k <> 'boundary' then + bad = k; + end if; + }); + except + raise 'http-multipart: opts must be a set'; + end try; + if listish then raise 'http-multipart: opts must be a set'; end if; + if bad <> '' then raise 'http-multipart: unknown option: ' & bad; end if; + if o.@has('boundary') then + var b = o['boundary']; + _mpwString(b, 'boundary'); + if not _mpwBoundaryValid(b) then raise 'http-multipart: invalid boundary'; end if; + custom = b; + end if; + end if; + + fun checkFileOpts(opts) + if opts = nil then result = true; return; end if; + var bad = ''; + var listish = false; + try + opts.@each((v, k){ + if k = nil or k = '' then + listish = true; + elsif k <> 'contentType' then + bad = k; + end if; + }); + except + raise 'http-multipart: file opts must be a set'; + end try; + if listish then raise 'http-multipart: file opts must be a set'; end if; + if bad <> '' then raise 'http-multipart: unknown option: ' & bad; end if; + result = true; + end fun; + + fun fileContentType(opts) + result = 'application/octet-stream'; + if opts = nil or not opts.@has('contentType') then return; end if; + var ct = opts['contentType']; + _mpwString(ct, 'contentType'); + if ct.length() = 0 then raise 'http-multipart: contentType must not be empty'; end if; + _mpwHeaderSafe(ct, 'contentType'); + result = ct; + end fun; + + fun field(name, value) + if this.finished then raise 'http-multipart: writer is finished'; end if; + _mpwString(name, 'name'); + if name.length() = 0 then raise 'http-multipart: name is required'; end if; + _mpwHeaderSafe(name, 'name'); + _mpwString(value, 'field value'); + var p = new []; + p.name = name; + p.isFile = false; + p.filename = nil; + p.contentType = nil; + p.data = value; + this.parts.@add(p); + result = true; + end fun; + + fun file(name, filename, data, opts) + if this.finished then raise 'http-multipart: writer is finished'; end if; + _mpwString(name, 'name'); + if name.length() = 0 then raise 'http-multipart: name is required'; end if; + _mpwHeaderSafe(name, 'name'); + _mpwString(filename, 'filename'); + _mpwHeaderSafe(filename, 'filename'); + _mpwString(data, 'file data'); + this.checkFileOpts(opts); + var ct = this.fileContentType(opts); + var p = new []; + p.name = name; + p.isFile = true; + p.filename = filename; + p.contentType = ct; + p.data = data; + this.parts.@add(p); + result = true; + end fun; + + fun dataCollides(b) + var i = 0; + while i < this.parts.@count() do + if _mpwDataCollides(this.parts[i].data, b) then result = true; return; end if; + i += 1; + end do; + result = false; + end fun; + + fun ensureBoundary() + if this.boundaryValue <> nil then result = true; return; end if; + if this.custom <> nil then + this.boundaryValue = this.custom; + result = true; + return; + end if; + var tries = 0; + loop + var b = 'fun-' & UUID(); + if not this.dataCollides(b) then + this.boundaryValue = b; + exit; + end if; + tries += 1; + if tries >= 10 then raise 'http-multipart: cannot choose a boundary'; end if; + end loop; + result = true; + end fun; + + fun contentType() + this.ensureBoundary(); + var b = this.boundaryValue; + if _mpwNeedsQuote(b) then b = '"' & b & '"'; end if; + result = 'multipart/form-data; boundary=' & b; + end fun; + + fun body() + if this.finished then result = this.bodyText; return; end if; + this.ensureBoundary(); + if this.dataCollides(this.boundaryValue) then + raise 'http-multipart: boundary appears in data'; + end if; + var out = new []; + var i = 0; + while i < this.parts.@count() do + var p = this.parts[i]; + out.@add('--' & this.boundaryValue & _MP_CRLF); + var cd = 'Content-Disposition: form-data; name="' & _mpwEscape(p.name) & '"'; + if p.isFile then cd &= '; filename="' & _mpwEscape(p.filename) & '"'; end if; + out.@add(cd & _MP_CRLF); + if p.isFile then out.@add('Content-Type: ' & p.contentType & _MP_CRLF); end if; + out.@add(_MP_CRLF); + out.@add(p.data); + out.@add(_MP_CRLF); + i += 1; + end do; + out.@add('--' & this.boundaryValue & '--' & _MP_CRLF); + this.bodyText = out.@toJson(-1); + this.finished = true; + result = this.bodyText; + end fun; +end class; + +fun HttpMultipartWriter(opts) + result = _MPW(opts); +end fun; diff --git a/fun/test/expected/lib-http-multipart-test.fun.txt b/fun/test/expected/lib-http-multipart-test.fun.txt index 1e040e4..3c5ee60 100644 --- a/fun/test/expected/lib-http-multipart-test.fun.txt +++ b/fun/test/expected/lib-http-multipart-test.fun.txt @@ -141,3 +141,68 @@ True True True True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True +True diff --git a/fun/test/lib-http-multipart-test.fun b/fun/test/lib-http-multipart-test.fun index 3f0d312..84915b2 100644 --- a/fun/test/lib-http-multipart-test.fun +++ b/fun/test/lib-http-multipart-test.fun @@ -342,3 +342,152 @@ r.body = 'a=1&b=2'; r.headS['content-type'] = 'application/x-www-form-urlencoded'; ?. r.form()['a'] = '1'; ?. r.form()['b'] = '2'; + +// --- writer: basic fields, file and round-trip --------------------------- +var w1 = HttpMultipartWriter([boundary: 'fun-w1']); +?. w1.contentType() = 'multipart/form-data; boundary=fun-w1'; +w1.field('user', 'alice'); +w1.field('tag', 'one'); +w1.field('tag', 'two'); +w1.file('avatar', 'a.txt', 'hello', [contentType: 'text/plain']); +var wb1 = w1.body(); +?. w1.body() = wb1; +?. wb1.substr(0, ('--fun-w1' & CRLF).length()) = '--fun-w1' & CRLF; +?. wb1.subpos('--fun-w1--' & CRLF) = wb1.length() - ('--fun-w1--' & CRLF).length(); +var wm1 = HttpMultipartParse(wb1, w1.contentType()); +?. wm1['ok']; +?. wm1['parts'].@count() = 4; +?. wm1['fields']['user'][0] = 'alice'; +?. wm1['fields']['tag'].@count() = 2; +?. wm1['fields']['tag'][0] = 'one'; +?. wm1['fields']['tag'][1] = 'two'; +?. wm1['files']['avatar'][0]['filename'] = 'a.txt'; +?. wm1['files']['avatar'][0]['contentType'] = 'text/plain'; +?. wm1['files']['avatar'][0]['data'] = 'hello'; +?. wm1['parts'][0]['contentType'] = 'text/plain'; + +// --- writer: file defaults, empty filename, empty writer ----------------- +var w2 = HttpMultipartWriter([boundary: 'fun-w2']); +w2.file('f', 'x.bin', 'abc'); +var wm2 = HttpMultipartParse(w2.body(), w2.contentType()); +?. wm2['files']['f'][0]['contentType'] = 'application/octet-stream'; + +var w3 = HttpMultipartWriter([boundary: 'fun-w3']); +w3.file('empty', '', ''); +var wb3 = w3.body(); +?. wb3.subpos('filename=""') >= 0; +var wm3 = HttpMultipartParse(wb3, w3.contentType()); +?. wm3['files']['empty'].@count() = 1; +?. wm3['files']['empty'][0]['filename'].type() = 8; +?. wm3['files']['empty'][0]['filename'].length() = 0; +?. wm3['files']['empty'][0]['data'].length() = 0; + +var w4 = HttpMultipartWriter([boundary: 'fun-w4']); +var wb4 = w4.body(); +?. wb4 = '--fun-w4--' & CRLF; +var wm4 = HttpMultipartParse(wb4, w4.contentType()); +?. wm4['ok']; +?. wm4['parts'].@count() = 0; + +// --- writer: binary, Unicode, embedded CRLF ------------------------------ +var wbytes = ''; +for i = 0 to 255 do wbytes &= i.toChar(); end do; +var w5 = HttpMultipartWriter([boundary: 'fun-w5']); +w5.field('bin', wbytes); +w5.file('blob', 'b.bin', wbytes); +var wm5 = HttpMultipartParse(w5.body(), w5.contentType()); +?. wm5['fields']['bin'][0].length() = 256; +?. wm5['fields']['bin'][0].toByte(0) = 0; +?. wm5['fields']['bin'][0].toByte(255) = 255; +?. wm5['files']['blob'][0]['data'].length() = 256; +?. wm5['files']['blob'][0]['data'].toByte(128) = 128; + +var w6 = HttpMultipartWriter([boundary: 'fun-w6']); +w6.field('名字', '张三'); +w6.file('文件', '报告.txt', '内容'); +var wm6 = HttpMultipartParse(w6.body(), w6.contentType()); +?. wm6['fields']['名字'][0] = '张三'; +?. wm6['files']['文件'][0]['filename'] = '报告.txt'; +?. wm6['files']['文件'][0]['data'] = '内容'; + +var w7 = HttpMultipartWriter([boundary: 'fun-w7']); +w7.field('note', 'a' & CRLF & 'b' & CRLF & '--not-the-boundary'); +w7.file('raw', 'r.txt', 'x' & CRLF & '--other' & CRLF & 'y'); +var wm7 = HttpMultipartParse(w7.body(), w7.contentType()); +?. wm7['fields']['note'][0] = 'a' & CRLF & 'b' & CRLF & '--not-the-boundary'; +?. wm7['files']['raw'][0]['data'] = 'x' & CRLF & '--other' & CRLF & 'y'; + +// --- writer: quoted names and quoted boundary ---------------------------- +var wbs = 92.toChar(); +var wq = 34.toChar(); +var w8 = HttpMultipartWriter([boundary: 'fun-w8']); +w8.field('a' & wq & 'b', 'v'); +w8.file('c' & wbs & 'd', 'e' & wq & 'f.txt', 'x'); +var wm8 = HttpMultipartParse(w8.body(), w8.contentType()); +?. wm8['fields']['a' & wq & 'b'][0] = 'v'; +?. wm8['files']['c' & wbs & 'd'][0]['filename'] = 'e' & wq & 'f.txt'; + +var w9 = HttpMultipartWriter([boundary: 'a b(c)']); +?. w9.contentType() = 'multipart/form-data; boundary="a b(c)"'; +w9.field('x', 'y'); +var wm9 = HttpMultipartParse(w9.body(), w9.contentType()); +?. wm9['ok']; +?. wm9['fields']['x'][0] = 'y'; + +// --- writer: default boundary and locked boundary ------------------------ +var wbp = 'multipart/form-data; boundary='; +var w10 = HttpMultipartWriter(); +var wct10 = w10.contentType(); +?. wct10.substr(0, wbp.length()) = wbp; +var wbnd = wct10.substr(wbp.length()); +?. wbnd.substr(0, 4) = 'fun-'; +?. wbnd.length() = 40; +w10.field('k', 'v'); +var wb10 = w10.body(); +?. wb10.substr(0, ('--' & wbnd & CRLF).length()) = '--' & wbnd & CRLF; +var wm10 = HttpMultipartParse(wb10, wct10); +?. wm10['ok']; +?. wm10['fields']['k'][0] = 'v'; + +var w11 = HttpMultipartWriter(); +var wct11 = w11.contentType(); +w11.field('k', 'v'); +?. w11.contentType() = wct11; + +// A boundary-looking prefix that is not a delimiter stays data. +var w12 = HttpMultipartWriter([boundary: 'hit']); +w12.field('a', '--hitz' & CRLF & 'ok'); +w12.field('b', 'x' & CRLF & '--hitz' & CRLF & 'y'); +var wm12 = HttpMultipartParse(w12.body(), w12.contentType()); +?. wm12['ok']; +?. wm12['fields']['a'][0] = '--hitz' & CRLF & 'ok'; +?. wm12['fields']['b'][0] = 'x' & CRLF & '--hitz' & CRLF & 'y'; + +// --- writer: invalid input raises ---------------------------------------- +fun mpwRaise(fn) + var bad = false; + try + fn(); + except + bad = true; + end try; + result = bad; +end fun; + +?. mpwRaise(((){ var wx = HttpMultipartWriter([boundary: 'bad' & wq & 'b']); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter([boundary: 'a'.x(71)]); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter([boundary: '']); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter([nope: 1]); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter('x'); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter(); wx.field('', 'v'); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter(); wx.field('a' & CRLF, 'v'); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter(); wx.field('a', 1); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter(); wx.file('a', 1, 'x'); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter(); wx.file('a', 'b', 1); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter(); wx.file('a', 'b', 'c', [contentType: 'text/plain' & CRLF]); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter(); wx.file('a', 'b', 'c', [nope: 1]); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter(); wx.field('a', 'b'); wx.body(); wx.field('c', 'd'); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter([boundary: 'hit']); wx.field('a', 'x' & CRLF & '--hit' & CRLF); wx.body(); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter([boundary: 'hit']); wx.field('a', '--hit' & CRLF); wx.body(); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter([boundary: 'hit']); wx.field('a', 'x' & CRLF & '--hit'); wx.body(); })); +?. mpwRaise(((){ var wx = HttpMultipartWriter([boundary: 'hit']); wx.field('a', '--hit'); wx.body(); }));