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)
    end

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

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 =
      parts
      |> Enum.filter(fn
        {_, [{"content-type", _}, {"content-disposition", _}], %Plug.Upload{}} = part ->
          part

        {_, [_], _} ->
          nil
      end)

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

    key = elem(hd(filtered), 0)
    l = length(filtered)
    new_keys = keys = Enum.map(1..l, 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]
      end)

    f ++ other
  end

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

#Plug.Parser.MY_MULTIPART
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)
      end

    {:ok, Plug.Conn.Query.decode_done(acc, []), conn}
  end
#router
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, []}
  end

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:

<html><body>
<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>
</form>
  
<script>
  const form = ({ method, action } = document.forms[0]);
  form.onsubmit=  async (e) => {
    e.preventDefault();
    return fetch(action, { method, body: new FormData(form) })
      .then((r) => r.json())
      .then(console.log);
   }); 
</script>
</body></html>