api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
3 changed files with 105 additions and 23 deletions
Showing only changes of commit f746e7a30d - Show all commits

feat(api): plain form-field multipart variant for sticker uploads

Luna 2026-07-05 06:16:35 -03:00

View file

@ -30,6 +30,12 @@ defmodule Dexcord.Api do
encoded as `multipart/form-data` with a `payload_json` part plus one `files[n]`
part per file (see "Uploading Files" in the Discord reference). This form is not
part of the public contract; external callers should pass plain maps/lists.
A second internal shape, `{:form_multipart, fields_map, file}`, encodes plain
form-field multipart (Discord's "Create Guild Sticker"): each `fields_map` key
becomes its own bare form part and `file` (a `%{filename, data, content_type}`
map) rides in a single part named `file` NOT the `payload_json`/`files[n]`
shape above.
"""
alias Dexcord.Api.Error
@ -67,7 +73,12 @@ defmodule Dexcord.Api do
when the internal waits would exceed `:api_deadline_ms`. For a non-JSON
error body a bounded snippet of the raw body is kept in `:message`.
"""
@spec request(atom(), String.t(), map() | list() | {:multipart, map(), list()} | nil, keyword()) ::
@spec request(
atom(),
String.t(),
map() | list() | {:multipart, map(), list()} | {:form_multipart, map(), map()} | nil,
keyword()
) ::
{:ok, map()} | {:ok, nil} | {:ok, {:raw, binary()}} | {:error, Error.t()}
def request(method, path, body \\ nil, opts \\ []) do
route = Ratelimit.route_key(method, path)
@ -185,6 +196,9 @@ defmodule Dexcord.Api do
{:multipart, payload_map, files} ->
build_multipart(payload_map, files, headers)
{:form_multipart, fields, file} ->
build_form_multipart(fields, file, headers)
body ->
{[{"content-type", "application/json"} | headers], JSON.encode!(body)}
end
@ -194,41 +208,67 @@ defmodule Dexcord.Api do
# a `payload_json` part carrying the JSON body, followed by one `files[n]` part
# per attachment (referenced from the payload's `attachments` array by index).
defp build_multipart(payload_map, files, headers) do
boundary =
"dexcord" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false)
payload_part = [
"--",
boundary,
"\r\n",
"Content-Disposition: form-data; name=\"payload_json\"\r\n",
"Content-Type: application/json\r\n\r\n",
JSON.encode!(payload_map),
"\r\n"
JSON.encode!(payload_map)
]
file_parts =
files
|> Enum.with_index()
|> Enum.map(fn {%{filename: filename, data: data, content_type: ctype}, n} ->
file_part("files[#{n}]", filename, ctype, data)
end)
multipart_wrap([payload_part | file_parts], headers)
end
# Plain form-field `multipart/form-data` (Discord "Create Guild Sticker"): each
# payload key is its own form-data part carrying a bare scalar value, plus a
# single file part named literally `file` — NOT the `payload_json` + `files[n]`
# shape. `file` is a `%{filename, data, content_type}` map.
defp build_form_multipart(
fields,
%{filename: filename, data: data, content_type: ctype},
headers
) do
field_parts =
Enum.map(fields, fn {key, value} ->
[
"--",
boundary,
"\r\n",
"Content-Disposition: form-data; name=\"files[",
Integer.to_string(n),
"]\"; filename=\"",
escape_filename(filename),
"\"\r\n",
"Content-Type: ",
ctype,
"\r\n\r\n",
data,
"\r\n"
"Content-Disposition: form-data; name=\"",
to_string(key),
"\"\r\n\r\n",
to_string(value)
]
end)
body = IO.iodata_to_binary([payload_part, file_parts, "--", boundary, "--\r\n"])
multipart_wrap(field_parts ++ [file_part("file", filename, ctype, data)], headers)
end
# A file part's iodata: content-disposition (with filename) + content-type + data.
defp file_part(name, filename, content_type, data) do
[
"Content-Disposition: form-data; name=\"",
name,
"\"; filename=\"",
escape_filename(filename),
"\"\r\n",
"Content-Type: ",
content_type,
"\r\n\r\n",
data
]
end
# Wraps a list of part iodatas (each the bytes AFTER the boundary line) in a
# fresh random boundary and returns `{headers, body}`.
defp multipart_wrap(parts, headers) do
boundary =
"dexcord" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false)
framed = Enum.map(parts, fn part -> ["--", boundary, "\r\n", part, "\r\n"] end)
body = IO.iodata_to_binary([framed, "--", boundary, "--\r\n"])
{[{"content-type", "multipart/form-data; boundary=" <> boundary} | headers], body}
end

View file

@ -236,11 +236,23 @@ defmodule Dexcord.Api.Endpoint do
{:multipart, attach_ids(payload, normalized),
Enum.map(normalized, &Map.delete(&1, :description))}
# Plain form-field multipart (sticker create): exactly one file, and every
# payload value goes on the wire as a bare string form part.
{:form, [file]} ->
{:form_multipart, stringify_values(payload),
Map.delete(normalize_file(file), :description)}
_ ->
payload
end
end
# Form-field parts carry bare scalars, so non-binary values are stringified.
defp stringify_values(payload) when is_map(payload),
do: Map.new(payload, fn {k, v} -> {k, to_string(v)} end)
defp stringify_values(_payload), do: %{}
@doc false
def encode_body(nil, _spec), do: nil

View file

@ -387,6 +387,36 @@ defmodule Dexcord.ApiIntegrationTest do
refute file_part.filename =~ "\""
assert file_part.filename == "ev_il.png"
end
test "a form_multipart body emits each field as its own part plus a file part" do
FakeRest.stub(:post, "/guilds/1/stickers", FakeRest.resp(201, body: ~s({"id":"1"})))
fields = %{"name" => "s", "tags" => "t"}
file = %{filename: "s.png", data: <<1>>, content_type: "image/png"}
assert {:ok, %{"id" => "1"}} =
Api.request(:post, "/guilds/1/stickers", {:form_multipart, fields, file})
assert_receive {:rest_hit, info}
headers = Map.new(info.headers)
assert "multipart/form-data; boundary=" <> boundary = headers["content-type"]
assert boundary != ""
parts = parse_multipart(info.body, boundary)
named = by_name(parts)
# Each field is its own plain form part (no filename, no content-type).
assert named["name"].data == "s"
assert named["name"].filename == nil
assert named["tags"].data == "t"
assert named["tags"].filename == nil
# The file rides in a part literally named "file" (not files[n]).
assert %{"file" => file_part} = named
assert file_part.filename == "s.png"
assert file_part.content_type == "image/png"
assert file_part.data == <<1>>
end
end
# --- helpers ------------------------------------------------------------