///|
struct FormWriter {
writer : Writer
}
///|
/// Create a form writer. The boundary defaults to 192 bits of platform
/// entropy, so that it cannot occur in the part bodies (RFC 2046 section
/// 5.1.1). Pass one explicitly when the platform cannot provide secure
/// randomness, or when the wire bytes must be reproducible; in that case it is
/// the caller's responsibility that no part body contains it.
pub fn FormWriter::FormWriter(
target : &@io.Writer,
boundary? : String,
) -> Self raise MultipartError {
let boundary = match boundary {
Some(explicit) => explicit
None => {
guard @env.rand(24) is Some(bytes) else {
raise MultipartError(
"secure randomness is unavailable for multipart boundary",
)
}
@hex.encode(bytes)
}
}
{ writer: Writer(target, boundary~), }
}
///|
/// The generated boundary, for constructing a matching reader or MIME header.
pub fn FormWriter::boundary(self : Self) -> String {
self.writer.boundary
}
///|
/// The HTTP Content-Type for the generated form body.
pub fn FormWriter::content_type(self : Self) -> String {
let boundary = self.writer.boundary
// These are the spaces and MIME tspecials allowed by validate_boundary.
// Validation excludes quotes and backslashes, so no escaping is needed.
if boundary.any(ch => ch is (' ' | '(' | ')' | ',' | '/' | ':' | '=' | '?')) {
"multipart/form-data; boundary=\"\{boundary}\""
} else {
"multipart/form-data; boundary=\{boundary}"
}
}
///|
/// Write a part with caller-supplied MIME headers.
pub async fn FormWriter::write_part(
self : Self,
headers~ : @http.Headers,
callback : Callback,
) -> Unit {
self.writer.write_part(headers~, callback)
}
///|
/// Write a UTF-8 text field, preserving the value's line endings.
pub async fn FormWriter::write_field(
self : Self,
name : String,
value : String,
) -> Unit {
let headers : @http.Headers = {
"Content-Disposition": form_content_disposition(name),
}
self.write_part(headers~, target => target.write(value))
}
///|
/// Write a JSON field with Content-Type application/json.
pub async fn FormWriter::write_json(
self : Self,
name : String,
value : Json,
) -> Unit {
let headers : @http.Headers = {
"Content-Disposition": form_content_disposition(name),
"Content-Type": "application/json",
}
self.write_part(headers~, target => target.write(value))
}
///|
/// Stream a file without closing its reader. An unspecified content type uses
/// application/octet-stream; filename is optional (RFC 7578 sections 4.2–4.4).
pub async fn FormWriter::write_file(
self : Self,
name : String,
filename? : String,
content_type? : String,
file : &@io.Reader,
) -> Unit {
let headers : @http.Headers = {
"Content-Disposition": form_content_disposition(name, filename?),
"Content-Type": content_type.unwrap_or("application/octet-stream"),
}
self.writer.write_reader(headers~, file)
}
///|
pub async fn FormWriter::finish(self : Self) -> Unit {
self.writer.finish()
}
///|
// MIME quoted-pair escaping preserves literal backslashes and quotes. CR/LF
// uses RFC 7578 section 2 percent encoding, as in Go's FileContentDisposition.
// One pass over the value, so no rule can feed another; the quoted-string
// decoder in parser.mbt is the other half of the quoted-pair rule. The
// disposition is assembled in one StringBuilder, so nothing is concatenated
// or copied through a per-field intermediate String.
fn form_content_disposition(name : String, filename? : String) -> String {
fn write_quoted(out : StringBuilder, value : String) -> Unit {
for ch in value {
match ch {
'\\' => out.write_string("\\\\")
'"' => out.write_string("\\\"")
'\r' => out.write_string("%0D")
'\n' => out.write_string("%0A")
_ => out.write_char(ch)
}
}
}
let disposition = StringBuilder()
disposition.write_string("form-data; name=\"")
write_quoted(disposition, name)
disposition.write_char('"')
if filename is Some(filename) {
disposition.write_string("; filename=\"")
write_quoted(disposition, filename)
disposition.write_char('"')
}
disposition.to_string()
}