///|
/// A single multipart/form-data field with an optional filename and media
/// type. Its body is bytes, or a stream read as the request is sent
/// (`Part::file_stream`).
pub(all) struct Part {
name : String
filename : String?
content_type : String?
body : @http.Body
} derive(Eq, Debug)
///|
/// Compares every part field, including the body bytes.
pub extend Part with Eq::{equal, not_equal}
///|
/// Formats a part for debugging.
pub extend Part with @debug.Debug::{to_repr}
///|
/// Creates a UTF-8 text field without a content type.
///
/// ```mbt check
/// test {
/// assert_eq(@multipart.Part::text("message", "hello").body, Bytes(b"hello"))
/// }
/// ```
pub fn Part::text(name : String, value : String) -> Part {
{
name,
filename: None,
content_type: None,
body: Bytes(@http.encode_utf8(value)),
}
}
///|
/// Creates a file field, preserving the supplied body bytes exactly.
///
/// ```mbt check
/// test {
/// let part = @multipart.Part::file(
/// "upload", "data.bin", "application/octet-stream", b"\x00\xFF",
/// )
/// assert_eq(part.filename, Some("data.bin"))
/// assert_eq(part.body, Bytes(b"\x00\xFF"))
/// }
/// ```
pub fn Part::file(
name : String,
filename : String,
content_type : String,
body : Bytes,
) -> Part {
{
name,
filename: Some(filename),
content_type: Some(content_type),
body: Bytes(body),
}
}
///|
/// Creates a file field whose body is read from `stream` as the request is
/// sent, so the file need not be held in memory. With `length`, the stream
/// must produce exactly that many bytes; when every part's length is known,
/// the multipart body is sent with `Content-Length`, otherwise chunked. The
/// part can be sent once, like any stream body (`@http.StreamBody`).
pub fn Part::file_stream(
name : String,
filename : String,
content_type : String,
stream : &@http.BodyStream,
length? : Int64,
) -> Part {
{
name,
filename: Some(filename),
content_type: Some(content_type),
body: Stream(@http.StreamBody::new(stream, length?)),
}
}
///|
/// Creates a JSON field encoded as UTF-8 with content type application/json.
///
/// ```mbt check
/// test {
/// let part = @multipart.Part::json("metadata", { "ok": true })
/// assert_eq(part.content_type, Some("application/json"))
/// assert_eq(part.body, Bytes(b"{\"ok\":true}"))
/// }
/// ```
pub fn Part::json(name : String, value : Json) -> Part {
{
name,
filename: None,
content_type: Some("application/json"),
body: Bytes(@http.encode_utf8(value.stringify())),
}
}
///|
/// Multipart validation and encoding failures.
pub(all) suberror MultipartError {
InvalidBoundary(String)
BoundaryCollision
InvalidHeaderValue(String)
/// The named part is a stream, which `encode` and `encode_segments` cannot
/// hold: use `encode_body` or `apply`.
StreamPart(String)
} derive(Debug)
///|
/// Formats a multipart failure for debugging.
pub extend MultipartError with @debug.Debug::{to_repr}
///|
fn is_boundary_char(c : Char) -> Bool {
c.is_ascii_alphabetic() ||
c.is_ascii_digit() ||
(match c {
'\''
| '('
| ')'
| '+'
| '_'
| ','
| '-'
| '.'
| '/'
| ':'
| '='
| '?'
| ' ' => true
_ => false
})
}
///|
fn boundary_needs_quotes(boundary : String) -> Bool {
boundary
.iter()
.any(c => {
match c {
'(' | ')' | ',' | '/' | ':' | '=' | '?' | ' ' => true
_ => false
}
})
}
///|
fn validate_boundary(boundary : String) -> Unit raise MultipartError {
if boundary.length() == 0 ||
boundary.length() > 70 ||
boundary.iter().any(c => !is_boundary_char(c)) ||
boundary.has_suffix(" ") {
raise InvalidBoundary(boundary)
}
}
///|
fn append_bytes(output : Array[Byte], bytes : Bytes) -> Unit {
output.push_iter(bytes.iter())
}
///|
fn append_text(output : Array[Byte], text : String) -> Unit {
append_bytes(output, @http.encode_utf8(text))
}
///|
fn append_escaped_parameter(output : Array[Byte], value : String) -> Unit {
for byte in @http.encode_utf8(value) {
match byte.to_int() {
34 => append_bytes(output, b"%22")
13 => append_bytes(output, b"%0D")
10 => append_bytes(output, b"%0A")
_ => output.push(byte)
}
}
}
///|
fn body_collides(body : Bytes, delimiter : Bytes, embedded : Bytes) -> Bool {
body.has_prefix(delimiter[:]) || body.find(embedded[:]) is Some(_)
}
///|
/// Encodes fields as multipart/form-data and returns its Content-Type value and
/// byte-exact body. A stream part raises `StreamPart`: see `encode_body`.
///
/// ```mbt check
/// test {
/// let (content_type, body) = @multipart.encode(
/// [@multipart.Part::text("message", "hello")],
/// "example",
/// )
/// assert_eq(content_type, "multipart/form-data; boundary=example")
/// assert_true(body.has_suffix(b"--example--\r\n"[:]))
/// }
/// ```
pub fn encode(
parts : Array[Part],
boundary : String,
) -> (String, Bytes) raise MultipartError {
let (content_type, segments) = encode_segments(parts, boundary)
let output : Array[Byte] = []
for segment in segments {
append_bytes(output, segment)
}
(content_type, Bytes::from_array(output))
}
///|
/// Encodes fields like `encode`, but returns the body as ordered segments
/// instead of one buffer: the framing between parts, and each part's body
/// exactly as supplied, not copied. The segments concatenated are the body
/// `encode` returns, so a host can stream a large upload without building it
/// in memory first. A stream part raises `StreamPart`.
///
/// ```mbt check
/// test {
/// let file = b"\x00\x01"
/// let (_, segments) = @multipart.encode_segments(
/// [@multipart.Part::file("upload", "a.bin", "application/octet-stream", file)],
/// "example",
/// )
/// assert_eq(segments.length(), 3)
/// assert_true(physical_equal(segments[1], file))
/// }
/// ```
pub fn encode_segments(
parts : Array[Part],
boundary : String,
) -> (String, Array[Bytes]) raise MultipartError {
let (content_type, pieces) = layout(parts, boundary)
let segments = pieces.map(piece => {
match piece {
Fixed(bytes) => bytes
Streamed(name, _) => raise StreamPart(name)
}
})
(content_type, segments)
}
///|
/// Encodes fields as a request body: the bytes `encode` returns when every
/// part is in memory, and otherwise a stream that reads each stream part as
/// it is sent. The stream has a known length, so a transport sends
/// `Content-Length`, only when every stream part was given one.
///
/// A stream part cannot be checked for the boundary before it is sent, so
/// each is scanned as it streams: one that contains the delimiter fails the
/// request with `HttpError::Protocol` before the colliding bytes go out.
/// With `make_boundary`'s 165 random bits a collision is not expected; the
/// scan makes it an error instead of a corrupt body.
///
/// ```mbt check
/// test {
/// let (_, body) = @multipart.encode_body(
/// [@multipart.Part::text("message", "hello")],
/// "B",
/// )
/// assert_eq(body.length(), Some(69L))
/// }
/// ```
pub fn encode_body(
parts : Array[Part],
boundary : String,
) -> (String, @http.Body) raise MultipartError {
let (content_type, pieces) = layout(parts, boundary)
(content_type, pieces_body(pieces, boundary))
}
///|
/// One stretch of an encoded body: framing and in-memory part bodies, or a
/// stream part's body with its part's name.
priv enum Piece {
Fixed(Bytes)
Streamed(String, @http.StreamBody)
}
///|
/// Validates `parts` and `boundary` and lays the body out as pieces. Bytes
/// parts are checked for the boundary here; stream parts as they stream.
fn layout(
parts : Array[Part],
boundary : String,
) -> (String, Array[Piece]) raise MultipartError {
validate_boundary(boundary)
let delimiter = @http.encode_utf8("--" + boundary)
let embedded = @http.encode_utf8("\r\n--" + boundary)
for part in parts {
if part.content_type is Some(content_type) &&
(content_type.contains("\r") || content_type.contains("\n")) {
raise InvalidHeaderValue(content_type)
}
if part.body is Bytes(body) && body_collides(body, delimiter, embedded) {
raise BoundaryCollision
}
}
let pieces : Array[Piece] = []
let framing : Array[Byte] = []
for part in parts {
append_text(framing, "--" + boundary + "\r\n")
append_text(framing, "Content-Disposition: form-data; name=\"")
append_escaped_parameter(framing, part.name)
append_text(framing, "\"")
if part.filename is Some(filename) {
append_text(framing, "; filename=\"")
append_escaped_parameter(framing, filename)
append_text(framing, "\"")
}
append_text(framing, "\r\n")
if part.content_type is Some(content_type) {
append_text(framing, "Content-Type: " + content_type + "\r\n")
}
append_text(framing, "\r\n")
match part.body {
Bytes(body) if body.is_empty() => ()
Bytes(body) => {
pieces.push(Fixed(Bytes::from_array(framing)))
framing.clear()
pieces.push(Fixed(body))
}
Stream(stream) => {
pieces.push(Fixed(Bytes::from_array(framing)))
framing.clear()
pieces.push(Streamed(part.name, stream))
}
}
append_text(framing, "\r\n")
}
append_text(framing, "--" + boundary + "--\r\n")
pieces.push(Fixed(Bytes::from_array(framing)))
let parameter = if boundary_needs_quotes(boundary) {
"\"" + boundary + "\""
} else {
boundary
}
("multipart/form-data; boundary=" + parameter, pieces)
}
///|
/// Creates a 40-character boundary from 32 base-36 random digits.
/// Values outside the random function's `[0, 1)` contract are clamped.
///
/// ```mbt check
/// test {
/// assert_eq(
/// @multipart.make_boundary(() => 0.0),
/// "mbt-sdk-00000000000000000000000000000000",
/// )
/// }
/// ```
pub fn make_boundary(random : () -> Double) -> String {
let alphabet = b"0123456789abcdefghijklmnopqrstuvwxyz"
let result = StringBuilder()
result.write_string("mbt-sdk-")
for _ in 0..<32 {
let value = random()
let index = if value.is_nan() || value <= 0.0 {
0
} else if value >= 1.0 {
35
} else {
(value * 36.0).floor().to_int()
}
result.write_char(alphabet[index].to_char())
}
result.to_string()
}
///|
/// Returns an independent request with an encoded multipart body
/// (`encode_body`) and replaces every existing Content-Type value.
///
/// ```mbt check
/// test {
/// let request = @http.Request::post("/upload").header("x-id", "42")
/// let result = @multipart.apply(
/// request,
/// [@multipart.Part::text("message", "hello")],
/// "example",
/// )
/// assert_eq(
/// result.headers.get("content-type"),
/// Some("multipart/form-data; boundary=example"),
/// )
/// assert_eq(request.headers.get("content-type"), None)
/// }
/// ```
pub fn apply(
request : @http.Request,
parts : Array[Part],
boundary : String,
) -> @http.Request raise MultipartError {
let (content_type, body) = encode_body(parts, boundary)
let headers = @http.Headers::from_array(request.headers.iter().to_array())
headers.set("content-type", content_type)
{ ..request, headers, body, }
}
///|
/// Encodes with a fresh random boundary, retrying only BoundaryCollision up to
/// `attempts` draws. Nonpositive attempts raise BoundaryCollision without drawing.
pub fn encode_segments_fresh(
parts : Array[Part],
random~ : () -> Double = @random.default_random,
attempts~ : Int32 = 16,
) -> (String, Array[Bytes]) raise MultipartError {
for _ in 0.. continue
other => raise other
} noraise {
result => return result
}
}
raise BoundaryCollision
}
///|
/// Joins encoded segments into one buffer, preserving their order.
pub fn join_segments(segments : Array[Bytes]) -> Bytes {
let size = segments.fold(init=0, (size, segment) => size + segment.length())
let buffer = Buffer(size_hint=size)
for segment in segments {
buffer.write_bytes(segment)
}
buffer.to_bytes()
}