lib/multipart.ex

defmodule Multipart do
  @moduledoc """
  `Multipart` constructs multipart messages.

  It aims to produce multipart messages that are compatible with [RFC
  2046](https://tools.ietf.org/html/rfc2046#section-5.1) for general use, and
  [RFC 7578](https://tools.ietf.org/html/rfc7578) for constructing
  `multipart/form-data` requests.
  """

  defstruct boundary: nil, parts: []

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

  @crlf "\r\n"
  @separator "--"

  alias Multipart.Part

  @doc """
  Create a new `Multipart` request.

  Pass in the boundary as the first argument to set it explicitly, otherwise
  it will default to a random 16 character alphanumeric string padded by `==`
  on either side.
  """
  @spec new(String.t()) :: t()
  def new(boundary \\ generate_boundary()) do
    %__MODULE__{boundary: boundary}
  end

  @doc """
  Adds a part to the `Multipart` message.
  """
  @spec add_part(t(), Multipart.Part.t()) :: t()
  def add_part(%__MODULE__{parts: parts} = multipart, %Part{} = part) do
    %__MODULE__{multipart | parts: parts ++ [part]}
  end

  @doc """
  Returns a `Stream` of the `Multipart` message body.
  """
  @spec body_stream(Multipart.t()) :: Enum.t()
  def body_stream(%__MODULE__{boundary: boundary, parts: parts}) do
    parts
    |> Enum.map(&part_stream(&1, boundary))
    |> Stream.concat()
    |> Stream.concat([final_delimiter(boundary)])
  end

  @doc """
  Returns a binary of the `Multipart` message body.

  This uses `body_stream/1` under the hood.
  """
  @spec body_binary(Multipart.t()) :: binary()
  def body_binary(%__MODULE__{} = multipart) do
    multipart
    |> body_stream()
    |> Enum.join("")
  end

  @doc """
  Returns the Content-Type header for the `Multipart` message.

      iex> multipart = Multipart.new("==abc123==")
      iex> Multipart.content_type(multipart, "multipart/mixed")
      "multipart/mixed; boundary=\\"==abc123==\\""
  """
  @spec content_type(Multipart.t(), String.t()) :: String.t()
  def content_type(%__MODULE__{boundary: boundary}, mime_type) do
    [mime_type, "boundary=\"#{boundary}\""]
    |> Enum.join("; ")
  end

  @doc """
  Returns the length of the `Multipart` message in bytes.

  It uses the `content_length` property in each of the message parts to
  calculate the length of the multipart message without reading the entire
  body into memory. `content_length` is set on the `Multipart.Part` by the
  constructor functions when possible, such as when the in-memory binary
  or the file on disk can be inspected.

  This will throw an error if any of the parts does not have `content_length`
  defined.
  """
  @spec content_length(Multipart.t()) :: pos_integer()
  def content_length(%__MODULE__{parts: parts, boundary: boundary}) do
    final_delimiter_length =
      final_delimiter(boundary)
      |> Enum.join("")
      |> byte_size()

    parts
    |> Enum.with_index()
    |> Enum.reduce(0, fn {%Part{} = part, index}, total ->
      case part_content_length(part, boundary) do
        cl when is_integer(cl) ->
          cl + total

        nil ->
          throw("Part at index #{index} has nil content_length")
      end
    end)
    |> Kernel.+(final_delimiter_length)
  end

  defp part_stream(%Part{} = part, boundary) do
    Stream.concat([part_delimiter(boundary), part_headers(part), part_body_stream(part)])
  end

  defp part_content_length(%Part{content_length: content_length} = part, boundary) do
    if is_integer(content_length) do
      Enum.concat(part_delimiter(boundary), part_headers(part))
      |> Enum.reduce(0, fn str, acc ->
        byte_size(str) + acc
      end)
      |> Kernel.+(content_length)
    else
      nil
    end
  end

  defp part_delimiter(boundary) do
    [@crlf, @separator, boundary, @crlf]
  end

  defp final_delimiter(boundary) do
    [@crlf, @separator, boundary, @separator, @crlf]
  end

  defp part_headers(%Part{headers: headers}) do
    headers
    |> Enum.flat_map(fn {k, v} ->
      ["#{k}: #{v}", @crlf]
    end)
    |> List.insert_at(-1, @crlf)
  end

  defp part_body_stream(%Part{body: body}) when is_binary(body) do
    [body]
  end

  defp part_body_stream(%Part{body: body}) when is_list(body) do
    body
  end

  defp part_body_stream(%Part{body: %type{} = body}) when type in [Stream, File.Stream] do
    body
  end

  defp generate_boundary() do
    token =
      16
      |> :crypto.strong_rand_bytes()
      |> Base.encode16(case: :lower)

    "==#{token}=="
  end
end