Multipart to params configuration - "MFA" - how to enable multiple file upload via a FormData?

I want to enable multiple file upload via a FormData. I imagine by playing with the Plug.Parser that it may be possible to achieve this.

[Multipart to params configuration]
(Plug.Parsers.MULTIPART — Plug v1.14.2)

“Once all multiparts are collected, they must be converted to params and this can be customize with a MFA.”

I have no idea when this is called for a start, nor what “MFA” stands for.

What I would like to evaluate is something like below where I index each received key to be unique, thus potentially (?) taken into account.

def multipart_to_params(parts, conn) do
  acc =
    for {name, _headers, body} <- Enum.reverse(parts),
        i <- 0..length(Enum.reverse(parts),
        name != nil,
        reduce: Plug.Conn.Query.decode_init() do
      acc -> Plug.Conn.Query.decode_each({name <> to_string(i), body}, acc)

   {:ok, Plug.Conn.Query.decode_done(acc), conn}

I can parse the parts, separate the headers with “content-type”. I would like to change the key, say increment it like:
[{"a",10},{"a",20}, {"a", 30}] to

[{"a-1", 10}, ,{"a-2",20}, {"a-3", 30}]

This should work if I inject it into multipart_to_params.

Something ugly like this seems to work, needs to adapt the controller now

def filter_content_type(parts) do
    filtered =
      |> Enum.filter(fn
        {_, [{"content-type", _}, {"content-disposition", _}], %Plug.Upload{}} = part ->

        {_, [_], _} ->

    other = Enum.filter(parts, fn elt -> !Enum.member?(filtered, elt) end)

    key = elem(hd(filtered), 0)
    l = length(filtered)
    new_keys = keys =, fn i -> key <> "#{i}" end)

    f =
      Enum.zip_reduce([filtered, new_keys], [], fn elts, acc ->
        [{_, headers, content}, new_key] = elts
        [{new_key, headers, content} | acc]

    f ++ other

if you don’t want to admit zero files, some guards must be used on length(filtered), depending what you want to do.

def multipart_to_params(parts, conn) do
    new_parts = filter_content_type(parts)

    acc =
      for {name, _headers, body} <- Enum.reverse(new_parts),
          reduce: Plug.Conn.Query.decode_init() do
        acc -> Plug.Conn.Query.decode_each({name, body}, acc)

    {:ok, Plug.Conn.Query.decode_done(acc, []), conn}
pipeline :api do
    plug :accepts, ["json"]

    plug CORSPlug,
      origin: ["http://localhost:3000", "http://localhost:4000"]

    plug Plug.Parsers,
      parsers: [:urlencoded, :my_multipart, :json],
      pass: ["image/jpg", "image/png", "image/webp", "image/jpeg"],
      json_decoder: Jason,
      multipart_to_params: {Plug.Parsers.MY_MULTIPART, :multipart_to_params, []},
      body_reader: {Plug.Parsers.MY_MULTIPART, :read_body, []}

so I get in the params:

params #=> %{
  "file1" => %Plug.Upload{
    path: "/var/folders/mz/91hbds1j23125yksdf67dcgm0000gn/T/plug-1696/multipart-1696237305-491320916966-6",
    content_type: "image/png",
    filename: "Screenshot2.png"
  "file2" => %Plug.Upload{
    path: "/var/folders/mz/91hbds1j23125yksdf67dcgm0000gn/T/plug-1696/multipart-1696237305-610219718990-6",
    content_type: "image/png",
    filename: "Screenshot1.png"
  "w" => "100",
  "thumb" => "on"

when I receive a FormData. This can be tested quickly:

<form id="f" action="http://localhost:4000/api" method="POST" enctype="multipart/form-data">
   <input type="file" name="file" multiple />
   <input type="number" name="width" />
   <input type="checkbox" name="thumb"/>
  <button form="f">Upload</button>
  const form = ({ method, action } = document.forms[0]);
  form.onsubmit=  async (e) => {
    return fetch(action, { method, body: new FormData(form) })
      .then((r) => r.json())