lib/tesla/multipart.ex

defmodule Tesla.Multipart do
  @moduledoc """
  Multipart functionality.

  ## Examples

  ```
  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")
    |> Multipart.add_file_content("sample file content", "sample.txt")

  response = client.post(url, mp)
  ```
  """

  defmodule Part do
    @moduledoc false

    defstruct body: nil,
              dispositions: [],
              headers: []

    @type t :: %__MODULE__{
            body: String.t(),
            headers: Tesla.Env.headers(),
            dispositions: Keyword.t()
          }
  end

  @type part_stream :: Enum.t()
  @type part_value :: iodata | part_stream

  defstruct parts: [],
            boundary: nil,
            content_type_params: []

  @type t :: %__MODULE__{
          parts: list(Tesla.Multipart.Part.t()),
          boundary: String.t(),
          content_type_params: [String.t()]
        }

  @doc """
  Create a new Multipart struct to be used for a request body.
  """
  @spec new() :: t
  def new do
    %__MODULE__{boundary: unique_string()}
  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 | no_return
  def add_field(%__MODULE__{} = mp, name, value, opts \\ []) do
    :ok = assert_part_value!(value)
    {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.

  ## Options

  - `:name` - name of form param
  - `:filename` - filename (defaults to path basename)
  - `:headers` - additional headers
  - `:detect_content_type` - auto-detect file content-type (defaults to false)
  """
  @spec add_file(t, String.t(), Keyword.t()) :: t
  def add_file(%__MODULE__{} = mp, path, opts \\ []) do
    {filename, opts} = Keyword.pop_first(opts, :filename, Path.basename(path))
    {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 -> List.keystore(headers, "content-type", 0, {"content-type", MIME.from_path(path)})
        false -> headers
      end

    data = File.stream!(path, [], 2048)
    add_file_content(mp, data, filename, opts ++ [headers: headers])
  end

  @doc """
  Add a file part with value.

  Same of `add_file/3` but the file content is read from `data` input argument.

  ## Options

  - `:name` - name of form param
  - `:headers` - additional headers
  """
  @spec add_file_content(t, part_value, String.t(), Keyword.t()) :: t
  def add_file_content(%__MODULE__{} = mp, data, filename, opts \\ []) do
    {name, opts} = Keyword.pop_first(opts, :name, "file")
    add_field(mp, name, data, opts ++ [filename: filename])
  end

  @doc false
  @spec headers(t) :: Tesla.Env.headers()
  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(Part.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

  @spec unique_string() :: String.t()
  defp unique_string() do
    16
    |> :crypto.strong_rand_bytes()
    |> Base.encode16(case: :lower)
  end

  @spec assert_part_value!(any) :: :ok | no_return
  defp assert_part_value!(%maybe_stream{})
       when maybe_stream in [IO.Stream, File.Stream, Stream],
       do: :ok

  defp assert_part_value!(value)
       when is_list(value)
       when is_binary(value),
       do: :ok

  defp assert_part_value!(val) do
    raise(ArgumentError, "#{inspect(val)} is not a supported multipart value.")
  end
end