diff --git a/lib/multipart.ex b/lib/multipart.ex index 4433c6c..0914a66 100644 --- a/lib/multipart.ex +++ b/lib/multipart.ex @@ -8,18 +8,18 @@ defmodule Multipart do `multipart/form-data` requests. """ + alias Multipart.Part + defstruct boundary: nil, parts: [] @type t :: %__MODULE__{ boundary: String.t(), - parts: list(Multipart.Part.t()) + parts: list(Part.t()) } @crlf "\r\n" @separator "--" - alias Multipart.Part - @doc """ Create a new `Multipart` request. @@ -43,7 +43,7 @@ defmodule Multipart do @doc """ Returns a `Stream` of the `Multipart` message body. """ - @spec body_stream(Multipart.t()) :: Enum.t() + @spec body_stream(t()) :: Enum.t() def body_stream(%__MODULE__{boundary: boundary, parts: parts}) do parts |> Enum.map(&part_stream(&1, boundary)) @@ -56,7 +56,7 @@ defmodule Multipart do This uses `body_stream/1` under the hood. """ - @spec body_binary(Multipart.t()) :: binary() + @spec body_binary(t()) :: binary() def body_binary(%__MODULE__{} = multipart) do multipart |> body_stream() @@ -70,7 +70,7 @@ defmodule Multipart do iex> Multipart.content_type(multipart, "multipart/mixed") "multipart/mixed; boundary=\\"==abc123==\\"" """ - @spec content_type(Multipart.t(), String.t()) :: String.t() + @spec content_type(t(), String.t()) :: String.t() def content_type(%__MODULE__{boundary: boundary}, mime_type) do [mime_type, "boundary=\"#{boundary}\""] |> Enum.join("; ") @@ -88,7 +88,7 @@ defmodule Multipart do This will throw an error if any of the parts does not have `content_length` defined. """ - @spec content_length(Multipart.t()) :: pos_integer() + @spec content_length(t()) :: pos_integer() def content_length(%__MODULE__{parts: parts, boundary: boundary}) do final_delimiter_length = final_delimiter(boundary) @@ -109,6 +109,15 @@ defmodule Multipart do |> Kernel.+(final_delimiter_length) end + @doc """ + Returns `Multipart` for a binary message body. + """ + @spec decode(String.t(), String.t()) :: {:ok, t()} | :error + def decode(boundary, @crlf <> data), + do: decode_parts(boundary, byte_size(boundary), data, []) + + def decode(_data), do: :error + defp part_stream(%Part{} = part, boundary) do Stream.concat([part_delimiter(boundary), part_headers(part), part_body_stream(part)]) end @@ -161,4 +170,55 @@ defmodule Multipart do "==#{token}==" end + + defp decode_parts(boundary, boundary_size, data, parts) do + case data do + <<@separator, ^boundary::binary-size(boundary_size), @separator, _rest::binary>> -> + {:ok, %__MODULE__{new(boundary) | parts: Enum.reverse(parts)}} + + <<@separator, ^boundary::binary-size(boundary_size), @crlf, rest::binary>> -> + with {:ok, part, rest} <- decode_part(rest, boundary) do + decode_parts(boundary, boundary_size, rest, [part | parts]) + end + end + end + + defp decode_part(data, boundary) do + with {:ok, headers, rest} <- decode_headers(data, []), + {:ok, body, rest} <- decode_body(rest, boundary, "") do + {:ok, %Part{headers: headers, body: body, content_length: byte_size(body)}, rest} + end + end + + defp decode_headers(<<@crlf, rest::binary>>, headers), + do: {:ok, Enum.reverse(headers), rest} + + defp decode_headers(data, headers) do + with {:ok, header, rest} <- decode_header(data, "") do + decode_headers(rest, [header | headers]) + end + end + + defp decode_header(<<@crlf, rest::binary>>, header), + do: {:ok, String.split(header, ": ", parts: 2) |> List.to_tuple(), rest} + + defp decode_header(<>, header), + do: decode_header(rest, header <> data) + + defp decode_header(_data, _headers), do: :error + + defp decode_body(rest, boundary, body) do + boundary_size = byte_size(boundary) + + case rest do + <<@crlf, @separator, ^boundary::binary-size(boundary_size), rest::binary>> -> + {:ok, body, @separator <> boundary <> rest} + + <> -> + decode_body(rest, boundary, body <> data) + + _ -> + :error + end + end end diff --git a/test/multipart_test.exs b/test/multipart_test.exs index c4b5311..e89f95c 100644 --- a/test/multipart_test.exs +++ b/test/multipart_test.exs @@ -20,6 +20,8 @@ defmodule MultipartTest do content_length = Multipart.content_length(multipart) assert content_length == byte_size(expected_output) + + assert {:ok, ^multipart} = Multipart.decode(@boundary, output) end test "building a message of file parts" do @@ -50,6 +52,8 @@ defmodule MultipartTest do content_length = Multipart.content_length(multipart) assert content_length == byte_size(expected_output) + + assert {:ok, ^multipart} = Multipart.decode(@boundary, output) end test "building a message of file form-data parts" do @@ -105,6 +109,8 @@ defmodule MultipartTest do content_length = Multipart.content_length(multipart) assert content_length == byte_size(expected_output) + + assert {:ok, ^multipart} = Multipart.decode(@boundary, output) end test "building a message preserves original line breaks" do