///|
/// One file to upload in a `multipart/form-data` request, next to the
/// `payload_json` part. Construct with `FileUpload(...)`.
pub struct FileUpload {
priv filename : String
priv content : Bytes
priv content_type : String
priv description : String?
priv title : String?
priv duration_secs : Double?
priv waveform : String?
priv is_spoiler : Bool?
}
///|
/// The supplied filename, before multipart encoding.
pub fn FileUpload::filename(self : FileUpload) -> String {
self.filename
}
///|
/// Immutable uploaded bytes. Never included in debug output.
pub fn FileUpload::content(self : FileUpload) -> Bytes {
self.content
}
///|
/// The supplied media type.
pub fn FileUpload::content_type(self : FileUpload) -> String {
self.content_type
}
///|
/// Keep uploaded bytes out of diagnostics while allowing captured interaction
/// outcomes to derive Debug.
pub impl Debug for FileUpload with fn to_repr(self) {
Repr(
(self.filename, self.content.length(), self.content_type, self.description),
)
}
///|
/// Describe one file to upload. `description` becomes the attachment's
/// alt text where the endpoint supports it; `title`, `duration_secs`,
/// `waveform`, and `is_spoiler` fill the matching attachment-request fields
/// (the latter two are required for voice messages).
///
/// ```mbt check
/// test "describe attachments in payload_json" {
/// let files = [
/// @http.FileUpload(
/// "cat.png",
/// b"PNG",
/// content_type="image/png",
/// description="a cat",
/// is_spoiler=true,
/// ),
/// ]
/// json_inspect(@http.attachments_json(files), content=[
/// {
/// "id": 0,
/// "filename": "cat.png",
/// "description": "a cat",
/// "is_spoiler": true,
/// },
/// ])
/// }
/// ```
pub fn FileUpload::FileUpload(
filename : String,
content : Bytes,
content_type? : String = "application/octet-stream",
description? : String,
title? : String,
duration_secs? : Double,
waveform? : String,
is_spoiler? : Bool,
) -> FileUpload {
{
filename,
content,
content_type,
description,
title,
duration_secs,
waveform,
is_spoiler,
}
}
///|
/// Reject upload metadata that could break out of a multipart part header.
fn validate_files(files : Array[FileUpload]) -> Unit raise DiscordHttpError {
for file in files {
if file.filename.is_empty() {
raise Validation(message="filename must not be empty")
}
if file.filename.contains("\r") ||
file.filename.contains("\n") ||
file.filename.contains("\"") {
raise Validation(
message="filename must not contain CR, LF, or double quotes",
)
}
if file.content_type.contains("\r") || file.content_type.contains("\n") {
raise Validation(message="content_type must not contain CR or LF")
}
}
}
///|
/// The `attachments` array announcing uploaded files inside `payload_json`:
/// `[{ "id": n, "filename": ..., "description"? : ... }]`. The typed
/// wrappers add this automatically; use it when building an
/// `InteractionResponse` with files by hand.
pub fn attachments_json(files : Array[FileUpload]) -> Json {
let items : Array[Json] = []
for i, file in files {
items.push(
@model.ObjBuilder()
.field("id", i)
.field("filename", file.filename)
.opt("description", file.description)
.opt("title", file.title)
.opt("duration_secs", file.duration_secs)
.opt("waveform", file.waveform)
.opt("is_spoiler", file.is_spoiler)
.build(),
)
}
Json::array(items)
}
///|
/// The `attachments` array for an edit: retained attachments first (by
/// snowflake id), then one entry per new upload with the numeric ids the
/// `files[n]` multipart parts require. `Some([])` with no files clears every
/// attachment; `None` leaves the retained set implicit (new files replace
/// the whole list, no files leaves it untouched).
fn merged_attachments_json(
keep : Array[@model.AttachmentRequest],
files : Array[FileUpload],
) -> Json {
let items : Array[Json] = []
for request in keep {
items.push(request.to_json())
}
match attachments_json(files) {
Array(uploads) => items.append(uploads)
_ => ()
}
Json::array(items)
}
///|
/// Attach the edit-time `attachments` field: an explicit `keep` list is
/// always sent (merged with any new uploads, `[]` clears), while without it
/// the field only announces fresh uploads, matching the create path.
fn edit_attachments_field(
body : @model.ObjBuilder,
keep : Array[@model.AttachmentRequest]?,
files : Array[FileUpload]?,
) -> @model.ObjBuilder {
match (keep, files) {
(Some(kept), _) =>
body.field(
"attachments",
merged_attachments_json(kept, files.unwrap_or([])),
)
(None, Some(fs)) if fs.length() > 0 =>
body.field("attachments", attachments_json(fs))
_ => body
}
}
///|
/// The `payload_json` part of an upload, sent first.
fn payload_part(payload_json : String) -> @multipart.Part {
{
name: "payload_json",
filename: None,
content_type: Some("application/json"),
body: @utf8.encode(payload_json),
}
}
///|
/// One file part under `name`, keeping the upload's bytes and media type.
fn file_part(name : String, file : FileUpload) -> @multipart.Part {
@multipart.Part::file(name, file.filename, file.content_type, file.content)
}
///|
/// The REST upload shape: `payload_json` first, then one `files[n]` part per
/// file.
fn upload_parts(
payload_json : String,
files : Array[FileUpload],
) -> Array[@multipart.Part] {
let parts = [payload_part(payload_json)]
for i, file in files {
parts.push(file_part("files[\{i}]", file))
}
parts
}
///|
/// A conventional form: text fields and one named file. Discord's
/// guild-sticker and invite target-user endpoints use this shape instead of
/// `payload_json` plus `files[n]`.
fn form_parts(
fields : Array[(String, String)],
file_field : String,
file : FileUpload,
) -> Array[@multipart.Part] {
let parts : Array[@multipart.Part] = []
for field in fields {
let (name, value) = field
parts.push(@multipart.Part::text(name, value))
}
parts.push(file_part(file_field, file))
parts
}
///|
let boundary_state : Ref[UInt64] = Ref(0UL)
///|
/// Boundary candidates from a small xorshift generator seeded from the clock.
/// Uniqueness does not rest on it: `encode_parts` retries a candidate that
/// occurs in a part.
fn boundary_random() -> Double {
if boundary_state.val == 0UL {
boundary_state.val = @clock.now_ms().reinterpret_as_uint64() |
0x9E3779B97F4A7C15UL
}
let mut x = boundary_state.val
x = x ^ (x << 13)
x = x ^ (x >> 7)
x = x ^ (x << 17)
boundary_state.val = x
(x >> 11).to_double() / 9007199254740992.0
}
///|
/// Encode the parts with a boundary that occurs in none of them. A candidate
/// that collides with a part is drawn again.
fn[T] encode_with_fresh_boundary(
parts : Array[@multipart.Part],
encoder : (Array[@multipart.Part], String) -> T raise @multipart.MultipartError,
random : () -> Double,
) -> T raise DiscordHttpError {
for _ in 0..<16 {
let boundary = @multipart.make_boundary(random)
let encoded = encoder(parts, boundary) catch {
@multipart.BoundaryCollision => continue
error => raise Validation(message=@debug.to_string(error))
}
return encoded
}
raise Transport(message="could not choose a multipart boundary")
}
///|
/// The body as ordered segments, for a wire request. Each part's bytes stay
/// the caller's, so nothing is copied until the segments are joined.
fn encode_parts(
parts : Array[@multipart.Part],
random? : () -> Double = boundary_random,
) -> (String, Array[Bytes]) raise DiscordHttpError {
encode_with_fresh_boundary(parts, @multipart.encode_segments, random)
}
///|
/// The wire request with its multipart `content-type`, and the body deferred:
/// the segments are joined only once the rate limiter admits an attempt, so
/// an upload waiting its turn holds no second copy of its files.
fn multipart_request(
request : @ghttp.Request,
parts : Array[@multipart.Part],
) -> WireRequest raise DiscordHttpError {
let (content_type, segments) = encode_parts(parts)
request.headers.set("content-type", content_type)
{ head: request, body: Some(() => join_segments(segments)), }
}
///|
/// The segments as one buffer, in order.
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()
}
///|
/// Encode an interaction-callback `multipart/form-data` body: the
/// `payload_json` part followed by one `files[n]` part per file, in the same
/// wire format as the REST upload path. Returns the `Content-Type` value
/// (including the boundary) and the ordered wire chunks; each file's bytes
/// are a chunk of their own, not copied, so a host can stream the reply by
/// writing the chunks in order.
/// Filenames are validated like the REST upload path.
///
/// ```mbt check
/// test "keep uploaded bytes as a multipart blob chunk" {
/// let content = b"hello"
/// let (content_type, chunks) = @http.encode_multipart_body("{\"type\":4}", [
/// FileUpload("hello.txt", content, content_type="text/plain"),
/// ])
/// assert_true(content_type.has_prefix("multipart/form-data; boundary="))
/// match chunks {
/// [payload_header, payload, file_header, bytes, trailer] => {
/// assert_true(@utf8.decode_lossy(payload_header).contains("payload_json"))
/// assert_eq(payload, b"{\"type\":4}")
/// assert_true(@utf8.decode_lossy(file_header).contains("files[0]"))
/// assert_true(physical_equal(bytes, content))
/// assert_true(@utf8.decode_lossy(trailer).has_suffix("--\r\n"))
/// }
/// _ => fail("unexpected multipart chunk layout")
/// }
/// }
/// ```
pub fn encode_multipart_body(
payload_json : String,
files : Array[FileUpload],
) -> (String, Array[Bytes]) raise DiscordHttpError {
validate_files(files)
encode_with_fresh_boundary(
upload_parts(payload_json, files),
@multipart.encode_segments,
boundary_random,
)
}