api-surface #1

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

feat(api): multipart payload_json + files[n] body support in request pipeline

Luna 2026-07-05 05:02:56 -03:00

View file

@ -17,6 +17,15 @@ defmodule Dexcord.Api do
The base URL defaults to `https://discord.com/api/v10` and is overridable with
`config :dexcord, :api_base_url` (used by the integration tests to point at a
fake server).
## Internal multipart form
As an internal detail used by the endpoint layer, `request/4` also accepts a
body of the shape `{:multipart, payload_map, files}` where `files` is a list of
`%{filename: String.t(), data: binary(), content_type: String.t()}`. It is
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.
"""
alias Dexcord.Api.Error
@ -54,7 +63,7 @@ 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() | nil, keyword()) ::
@spec request(atom(), String.t(), map() | list() | {:multipart, map(), list()} | 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)
@ -169,11 +178,60 @@ defmodule Dexcord.Api do
nil ->
{headers, nil}
{:multipart, payload_map, files} ->
build_multipart(payload_map, files, headers)
body ->
{[{"content-type", "application/json"} | headers], JSON.encode!(body)}
end
end
# Hand-rolled `multipart/form-data` per the Discord "Uploading Files" reference:
# 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"
]
file_parts =
files
|> Enum.with_index()
|> Enum.map(fn {%{filename: filename, data: data, content_type: ctype}, n} ->
[
"--",
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"
]
end)
body = IO.iodata_to_binary([payload_part, file_parts, "--", boundary, "--\r\n"])
{[{"content-type", "multipart/form-data; boundary=" <> boundary} | headers], body}
end
# Quotes, backslashes and CR/LF in a filename would corrupt the part header.
defp escape_filename(name), do: String.replace(name, ~r/["\\\r\n]/, "_")
defp base_url, do: Application.get_env(:dexcord, :api_base_url, @default_base_url)
defp deadline_ms, do: Application.get_env(:dexcord, :api_deadline_ms, @default_deadline_ms)

View file

@ -307,8 +307,121 @@ defmodule Dexcord.ApiIntegrationTest do
refute Map.has_key?(body, "rate_limit_per_user")
end
describe "multipart bodies" do
test "a single file becomes a payload_json part plus a files[0] part" do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
payload = %{"content" => "hi", "attachments" => [%{"id" => 0, "filename" => "a.png"}]}
files = [%{filename: "a.png", data: <<137, 80, 78, 71>>, content_type: "image/png"}]
assert {:ok, %{"id" => "1"}} =
Api.request(:post, "/channels/1/messages", {:multipart, payload, files})
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)
assert %{"payload_json" => payload_part} = by_name(parts)
assert payload_part.content_type == "application/json"
assert JSON.decode!(payload_part.data) == payload
assert %{"files[0]" => file_part} = by_name(parts)
assert file_part.filename == "a.png"
assert file_part.content_type == "image/png"
assert file_part.data == <<137, 80, 78, 71>>
end
test "two files become files[0] and files[1] parts in order" do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
files = [
%{filename: "a.png", data: <<1>>, content_type: "image/png"},
%{filename: "b.txt", data: <<2>>, content_type: "text/plain"}
]
assert {:ok, _} =
Api.request(:post, "/channels/1/messages", {:multipart, %{"content" => ""}, files})
assert_receive {:rest_hit, info}
"multipart/form-data; boundary=" <> boundary = Map.new(info.headers)["content-type"]
parts = parse_multipart(info.body, boundary)
names = Enum.map(parts, & &1.name)
assert names == ["payload_json", "files[0]", "files[1]"]
named = by_name(parts)
assert named["files[0]"].filename == "a.png"
assert named["files[0]"].data == <<1>>
assert named["files[1]"].filename == "b.txt"
assert named["files[1]"].data == <<2>>
end
test "a filename containing a quote arrives sanitized" do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
files = [%{filename: ~s(ev"il.png), data: <<0>>, content_type: "image/png"}]
assert {:ok, _} =
Api.request(:post, "/channels/1/messages", {:multipart, %{}, files})
assert_receive {:rest_hit, info}
"multipart/form-data; boundary=" <> boundary = Map.new(info.headers)["content-type"]
parts = parse_multipart(info.body, boundary)
assert %{"files[0]" => file_part} = by_name(parts)
refute file_part.filename =~ "\""
assert file_part.filename == "ev_il.png"
end
end
# --- helpers ------------------------------------------------------------
# Splits a multipart/form-data body into parsed parts, preserving raw part
# bytes (no trimming, so binary payloads survive intact).
defp parse_multipart(body, boundary) do
body
|> String.split("--" <> boundary)
|> Enum.drop(1)
|> Enum.reject(fn seg -> seg == "--\r\n" or seg == "--" end)
|> Enum.map(fn seg ->
seg = String.replace_prefix(seg, "\r\n", "")
[raw_headers, rest] = String.split(seg, "\r\n\r\n", parts: 2)
data = String.replace_suffix(rest, "\r\n", "")
disposition = header_value(raw_headers, "content-disposition")
%{
name: extract(disposition, ~r/name="([^"]*)"/),
filename: extract(disposition, ~r/filename="([^"]*)"/),
content_type: header_value(raw_headers, "content-type"),
data: data
}
end)
end
defp by_name(parts), do: Map.new(parts, fn part -> {part.name, part} end)
defp header_value(raw_headers, name) do
raw_headers
|> String.split("\r\n")
|> Enum.find_value(fn line ->
case String.split(line, ": ", parts: 2) do
[key, value] -> if String.downcase(key) == name, do: value
_ -> nil
end
end)
end
defp extract(nil, _re), do: nil
defp extract(string, re) do
case Regex.run(re, string) do
[_, captured] -> captured
_ -> nil
end
end
defp query(info) do
case info.query_string do
qs when is_binary(qs) and qs != "" -> URI.decode_query(qs)