defmodule Tesla.Multipart do
@moduledoc """
Multipart functionality.
Example:
```ex
mp =
Multipart.new
|> Multipart.add_content_type_param("charset=utf-8")
|> Multipart.add_field("field1", "foo")
|> Multipart.add_field("field2", "bar", headers: [{:"Content-Id", 1}, {:"Content-Type", "text/plain"}])
|> Multipart.add_file("test/tesla/multipart_test_file.sh")
|> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar")
response = client.post(url, mp)
```
"""
@boundary_chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" |> String.split("")
@type part_stream :: IO.Stream.t | File.Stream.t
@type part_value :: String.t | part_stream
defstruct [
parts: [],
boundary: nil,
content_type_params: []
]
@type t :: %__MODULE__ {
parts: list(Part.t),
boundary: String.t,
content_type_params: [String.t]
}
defmodule Part do
defstruct [
body: nil,
dispositions: [],
headers: [],
]
@type t :: %__MODULE__ {
body: String.t,
headers: Keyword.t,
dispositions: Keyword.t,
}
end
@doc """
Create a new Multipart struct to be used for a request body.
"""
@spec new() :: t
def new do
%__MODULE__{boundary: unique_string(32)}
end
@doc """
Add a parameter to the multipart content-type.
"""
@spec add_content_type_param(t, String.t) :: t
def add_content_type_param(%__MODULE__{} = mp, param) do
%{mp | content_type_params: mp.content_type_params ++ [param]}
end
@doc """
Add a field part.
"""
@spec add_field(t, String.t, part_value, Keyword.t) :: t
def add_field(%__MODULE__{} = mp, name, value, opts \\ []) do
{headers, opts} = Keyword.pop_first(opts, :headers, [])
part = %Part{
body: value,
headers: headers,
dispositions: [{:name, name}] ++ opts
}
%{mp | parts: mp.parts ++ [part]}
end
@doc """
Add a file part. The file will be streamed.
"""
@spec add_file(t, String.t, Keyword.t) :: t
def add_file(%__MODULE__{} = mp, filename, opts \\ []) do
{name, opts} = Keyword.pop_first(opts, :name, "file")
{headers, opts} = Keyword.pop_first(opts, :headers, [])
{detect_content_type, opts} = Keyword.pop_first(opts, :detect_content_type, false)
# add in detected content-type if necessary
headers = case detect_content_type do
true -> Keyword.put(headers, :"Content-Type", MIME.from_path(filename))
false -> headers
end
basename = Path.basename(filename)
opts =
opts
|> Keyword.put(:filename, basename)
|> Keyword.put(:headers, headers)
data = File.stream!(filename, [:read], 2048)
add_field(mp, name, data, opts)
end
@doc false
@spec headers(t) :: Keyword.t
def headers(%__MODULE__{boundary: boundary, content_type_params: params}) do
ct_params = (["boundary=#{boundary}"] ++ params) |> Enum.join("; ")
[{:"Content-Type", "multipart/form-data; #{ct_params}"}]
end
@doc false
@spec body(t) :: part_stream
def body(%__MODULE__{boundary: boundary, parts: parts}) do
part_streams = Enum.map(parts, &(part_as_stream(&1, boundary)))
Stream.concat(part_streams ++ [["--#{boundary}--\r\n"]])
end
@doc false
@spec part_as_stream(t, String.t) :: part_stream
def part_as_stream(%Part{body: body, dispositions: dispositions, headers: part_headers}, boundary) do
part_headers = Enum.map(part_headers, fn {k, v} -> "#{k}: #{v}\r\n" end)
part_headers = part_headers ++ [part_headers_for_disposition(dispositions)]
enum_body = case body do
b when is_binary(b)-> [b]
b -> b
end
Stream.concat([
["--#{boundary}\r\n"],
part_headers,
["\r\n"],
enum_body,
["\r\n"]
])
end
@doc false
@spec part_headers_for_disposition(Keyword.t) :: [String.t]
def part_headers_for_disposition([]), do: []
def part_headers_for_disposition(kvs) do
ds =
kvs
|> Enum.map(fn {k, v} -> "#{k}=\"#{v}\"" end)
|> Enum.join("; ")
["Content-Disposition: form-data; #{ds}\r\n"]
end
@doc false
@spec unique_string(pos_integer) :: String.t
defp unique_string(length) do
Enum.reduce((1..length), [], fn (_i, acc) ->
[Enum.random(@boundary_chars) | acc]
end) |> Enum.join("")
end
end