api-surface #1
2 changed files with 172 additions and 1 deletions
feat(api): multipart payload_json + files[n] body support in request pipeline
commit
c852d2a453
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue