// 表单请求体(对应 axios 的 `FormData`):值类型 + `multipart/form-data` 编码。
//
// 一律编码成 multipart——纯文本字段也走这条,与浏览器的 `FormData` 行为一致,
// 这样「有没有文件」不会让线上格式在两种形态之间切换。
// 文件按「字节 + 文件名」传入:本包不碰 IO,读盘由调用方自己决定。
///|
/// 一个文件字段:发给服务端的文件名、该部分的内容类型、内容字节。
pub(all) struct FormFile {
/// 文件名(`Content-Disposition` 的 `filename`),只能是文件名本身,不要带路径
filename : String
/// 该部分的内容类型,缺省 `application/octet-stream`
content_type : String
/// 文件字节
bytes : Bytes
}
///|
/// 表单字段的值:文本或文件。
pub(all) enum FormValue {
Text(String)
File(FormFile)
}
///|
/// 表单请求体:有序的「字段名 → 值」列表。
///
/// 同名可以出现多次(多个值、多个文件都会各自成为一项),顺序就是 `append_*` 的顺序,
/// 服务端通常按顺序取第一个同名项,所以「同名字段覆盖」这类语义不在这里做。
///
/// 与 `Headers` 一样是值语义:`append_*` 返回新实例,
/// 拿一份表单当模板派生出多份,互相之间不会被改坏。
pub struct FormData {
priv parts : Array[(String, FormValue)]
}
///|
/// 空表单。
pub fn FormData::new() -> FormData {
{ parts: [], }
}
///|
/// 追加一个文本字段,返回新的表单。
pub fn FormData::append_text(
self : FormData,
name : StringView,
value : String,
) -> FormData {
self.append(name, FormValue::Text(value))
}
///|
/// 追加一个文件字段,返回新的表单。
///
/// `content_type` 省略时按 `application/octet-stream` 发送;
/// 传具体类型(如 `"image/png"`)会让服务端更容易识别。
///
/// ```moonbit nocheck
/// @moonhttp.FormData::new()
/// .append_text("title", "假期照片")
/// .append_file("avatar", "a.png", bytes, content_type="image/png")
/// ```
pub fn FormData::append_file(
self : FormData,
name : StringView,
filename : StringView,
bytes : Bytes,
content_type? : String = "application/octet-stream",
) -> FormData {
self.append(
name,
FormValue::File({ filename: filename.to_owned(), content_type, bytes, }),
)
}
///|
/// 追加一项的公共实现,不导出:两个 `append_*` 只在「值怎么造」上不同。
fn FormData::append(
self : FormData,
name : StringView,
value : FormValue,
) -> FormData {
// 先 copy 再 push:parts 是共享的 Array,就地改会让别的表单跟着变。
let parts = []
for part in self.parts {
parts.push(part)
}
parts.push((name.trim().to_owned(), value))
{ parts, }
}
///|
/// 字段项数(文本项与文件项都算一项)。
pub fn FormData::length(self : FormData) -> Int {
self.parts.length()
}
///|
/// 是否一项都没有。
pub fn FormData::is_empty(self : FormData) -> Bool {
self.parts.is_empty()
}
///|
/// 展开为「字段名, 值」数组,顺序为追加顺序。
///
/// 与 `Headers::entries` 同理,返回的是新数组:调用方拿到的副本怎么改都不会影响本表单。
pub fn FormData::entries(self : FormData) -> Array[(String, FormValue)] {
let result = []
for part in self.parts {
result.push(part)
}
result
}
///|
/// 渲染为 `FormData { 名: 值, 名: @文件名 (内容类型, N bytes) }`,只用于日志与断言。
///
/// 文件不打印内容(只给字节数):表单经常整个进日志,把文件正文打进去没有意义。
pub fn FormData::to_string(self : FormData) -> String {
let buf = StringBuilder()
let mut first = true
for part in self.parts {
let (name, value) = part
first = push_field(buf, first, name, render_form_value(value))
}
render_braced("FormData", buf.to_string(), first)
}
///|
fn render_form_value(value : FormValue) -> String {
match value {
Text(text) => text
File(file) =>
"@" +
file.filename +
" (" +
file.content_type +
", " +
file.bytes.length().to_string() +
" bytes)"
}
}
///|
pub impl Show for FormData with fn to_string(self) {
self.to_string()
}
///|
pub extend FormData with Show::{output}
///|
/// 手写 Debug 而不是 `derive(Debug)`:渲染内容与 `Show` 保持一致,避免出现两套格式。
pub impl @debug.Debug for FormData with fn to_repr(self) {
@debug.Repr::string(self.to_string())
}
///|
pub extend FormData with @debug.Debug::{to_repr}
///|
/// 生成一个 multipart boundary:`----moonhttp-` 拼两个随机十进制数。
///
/// 只要足够不易与正文内容撞车即可(RFC 2046 要求 boundary 不出现在各部分内容里),
/// 不承担安全用途,所以直接用 core 的随机数。
fn random_boundary() -> String {
let rand = @random.Rand::new()
"----moonhttp-" + rand.uint64().to_string() + rand.uint64().to_string()
}
///|
/// 把一个表单编码成 `multipart/form-data` 正文(RFC 7578 / WHATWG 的字节布局):
///
/// ```text
/// --B\r\n
/// Content-Disposition: form-data; name="title"\r\n
/// \r\n
/// 假期\r\n
/// --B\r\n
/// Content-Disposition: form-data; name="avatar"; filename="a.png"\r\n
/// Content-Type: image/png\r\n
/// \r\n
/// <文件字节>\r\n
/// --B--\r\n
/// ```
///
/// 两处刻意的选择:
/// - **文本项不带 per-part `Content-Type`**:浏览器的 `FormData` 与 node 的
/// `form-data` 都是这样,多补一个只会让某些服务端把它当成文件;
/// - **结尾的分隔符总是发**:空表单也发 `--B--\r\n`,与浏览器一致(那仍然是一个合法的空表单)。
fn multipart_body(form : FormData, boundary : String) -> Bytes {
let buf = @buffer.Buffer::Buffer()
for part in form.parts {
let (name, value) = part
buf.write_string_utf8("--")
buf.write_string_utf8(boundary)
buf.write_string_utf8("\r\nContent-Disposition: form-data; name=\"")
buf.write_string_utf8(escape_disposition(name))
buf.write_string_utf8("\"")
match value {
Text(text) => {
buf.write_string_utf8("\r\n\r\n")
buf.write_string_utf8(text)
}
File(file) => {
buf.write_string_utf8("; filename=\"")
buf.write_string_utf8(escape_disposition(file.filename))
buf.write_string_utf8("\"\r\nContent-Type: ")
buf.write_string_utf8(file.content_type)
buf.write_string_utf8("\r\n\r\n")
buf.write_bytes(file.bytes)
}
}
buf.write_string_utf8("\r\n")
}
buf.write_string_utf8("--")
buf.write_string_utf8(boundary)
buf.write_string_utf8("--\r\n")
buf.to_bytes()
}
///|
/// `Content-Disposition` 里 `name` / `filename` 的转义,按 WHATWG 的表单编码规则:
/// `"` → `%22`、CR → `%0D`、LF → `%0A`,其余字符(含非 ASCII)原样按 UTF-8 写。
///
/// 这是刻意与 axios 的差异:它依赖的 node `form-data` 对这两个值不做任何转义,
/// 字段名或文件名里带引号时服务端会把参数解析错位,甚至能被注入额外的参数。
fn escape_disposition(value : StringView) -> String {
// 这里用 StringBuilder(UTF-16 语义),最后交给 Buffer 编码成 UTF-8;
// 只有 `write_char`/`write_string` 可用,没有 Buffer 那套按编码分家的写入方法。
let buf = StringBuilder(size_hint=value.length())
for c in value {
if c == '"' {
buf.write_string("%22")
} else if c == '\r' {
buf.write_string("%0D")
} else if c == '\n' {
buf.write_string("%0A")
} else {
buf.write_char(c)
}
}
buf.to_string()
}