api-surface #1
3 changed files with 105 additions and 23 deletions
feat(api): plain form-field multipart variant for sticker uploads
commit
f746e7a30d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue