How to test multipart HTTP POST with Elixir Phoenix Framework

I recently implemented an endpoint for uploading files via multipart HTTP POST with Phoenix, but I didn’t find a good documentation about how to test it, so I’m going to describe how I did it.

The test code

defmodule HelloWeb.MultipartControllerTest do
  use HelloWeb.ConnCase
  
  test "Multipart POST /api/files", %{conn: conn} do
  
    boundary = "ef593866da4b40b59ef73e2feaab7fb1"
    content = File.read!("test/hello_web/controllers/test.png")
    name = "test-file"
    filename = "test-file.png"
    content_type = "image/png"
    
    binary_body = """
    --#{boundary}\r
    Content-Disposition: form-data; name="#{name}"; filename="#{filename}"\r
    Content-Type: #{content_type}\r
    \r
    #{content}\r
    --#{boundary}--\r
    """
    
    conn =
      conn
      |> Plug.Conn.put_req_header("content-type", "multipart/form-data; boundary=#{boundary}")
      |> post(~p"/api/files", binary_body)
    
    assert %{
             ^name => %{
               "path" => path,
               "content_type" => ^content_type,
               "filename" => ^filename
             }
           } = json_response(conn, 200)
  end
end

The above code test a simple multipart POST request with a handmade binary body. Please note the \r at each end of line of the body binary and the content-type header that tells it is a multipart request.

The tested response is just an echo of what the controller receives. Let see how the controller is implemented.

The implementation

Controller code is the following:

defmodule HelloWeb.MultipartController do
  use HelloWeb, :controller
  
  require Protocol
  Protocol.derive(Jason.Encoder, Plug.Upload)
  
  def create(conn, params) do
    json(conn, params)
  end
end

It is just an echo of “params” and, as test proves, it contains the parsed informations of the multipart request plus the (temporary) path of the files uploaded.

Indeed the multipart requests are automatically parsed by Plug.Parser(.MULTIPART) and files are automatically stored in a temporary path by Plug.Upload.

Note that, as documented, if name key in Content-Disposition doesn’t exist then the plug will discard that part.

The configuration that tells Plug.Parser to parse multipart request is located in your Endpoint module, like the following:

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  json_decoder: Phoenix.json_library()

If you want to parse multipart requests by yourself you can remove :multipart from parser configuration and use Plug.Conn.read_part_headers/2 and Plug.Conn.read_part_body/2 in your controllers.

Other implementation details

Router:

defmodule HelloWeb.Router do
  use HelloWeb, :router
  
  pipeline :api do
    plug :accepts, ["json"]
  end
  
  scope "/api", HelloWeb do
    pipe_through :api
    
    post "/files", MultipartController, :create
  end
end

Deps:

defp deps do
  [
    {:phoenix, "~> 1.7.2"},
    {:jason, "~> 1.2"},
    {:plug_cowboy, "~> 2.5"}
  ]
end