open-api-spex / open_api_spex

Open API Specifications for Elixir Plug applications
Mozilla Public License 2.0
689 stars 180 forks source link

Sending multipart/form-data POST requests #620

Closed neslinesli93 closed 2 weeks ago

neslinesli93 commented 2 weeks ago

Hi, I'm trying to use your library to describe and endpoint that accepts a POST request with an array of multipart/form-data values, including files, but I'm failing to achieve that. This is my elixir schema file:

defmodule MyProject.Schemas do
  require OpenApiSpex
  alias OpenApiSpex.Schema

  defmodule FilesInput do
    OpenApiSpex.schema(%{
      description: "A file with content and metadata",
      type: :object,
      properties: %{
        files: %Schema{
          type: :array,
          items: %Schema{
            type: :object,
            properties: %{
              file: %Schema{
                type: :string,
                format: :binary
              },
              name: %Schema{
                type: :string
              },
              date: %Schema{
                type: :string,
                format: :date
              }
            },
            required: [:file, :name, :date]
          }
        }
      }
    })
  end

  defmodule FilesInputResponse do
    OpenApiSpex.schema(%{
      description: "Response schema after files upload",
      type: :object,
      properties: %{
        created: %Schema{type: :string, description: "OK"}
      },
      example: %{
        "created" => "ok"
      },
      "x-struct": __MODULE__
    })
  end
end

and here is my controller:

defmodule MyProject.DocumentsOpenapiController do
  use MyProject, :controller
  use OpenApiSpex.ControllerSpecs

  alias MyProject.Schemas.FilesInput
  alias MyProject.Schemas.FilesInputResponse

  plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true

  tags ["files"]

  operation :create,
    summary: "Upload a list of files",
    description: "Upload a list of files", 
    request_body: {"List of files", "multipart/form-data", FilesInput, required: true},
    responses: [
      ok: {"Upload result", "application/json", FilesInputResponse}
    ]

  def create(conn = %{body_params: %FilesInput{} = files_input}, _params) do
    IO.inspect(files_input)

    json(conn, %{created: "ok"})
  end
end

I can't seem to get this to work with either this curl (no array index):

curl -X 'POST' -v \
  'http://localhost:4002/files' \
  -H 'Content-Type: multipart/form-data' \
  -F 'files[][file]=@file1.jpg;type=image/jpeg;filename=test1' \
  -F 'files[][name]=Name1' \
  -F 'files[][date]=2024-01-01' \
  -F 'files[][file]=@file2.png;type=image/png;filename=test2' \
  -F 'files[][name]=Name2' \
  -F 'files[][date]=2024-02-01' \

or the same command but with indexes in arrays:

curl -X 'POST' -v \
  'http://localhost:4002/files' \
  -H 'Content-Type: multipart/form-data' \
  -F 'files[0][file]=@file1.jpg;type=image/jpeg;filename=test1' \
  -F 'files[0][name]=Name1' \
  -F 'files[0][date]=2024-01-01' \
  -F 'files[1][file]=@file2.png;type=image/png;filename=test2' \
  -F 'files[1][name]=Name2' \
  -F 'files[1][date]=2024-02-01' \

Is there a way to do this with the Openapi 3.0 spec and your library? Thank you

mbuhot commented 2 weeks ago

@neslinesli93 what error response do you get from the CURLs and what error logging output do you get from the server?

neslinesli93 commented 2 weeks ago

If i send the curl without indexes, only one element is picked, so the parsed+casted+validated body looks like this:

%MyProject.Schemas.FilesInput{
  files: [
    %{
      name: "Name2",
      date: ~D[2024-02-01],
      file: %Plug.Upload{
        path: "/tmp/plug-1720-22TL/multipart-1720510488-280198958045-1",
        content_type: "image/png",
        filename: "test2"
      }
    }
  ]
}

Otherwise, if I send the curl with indexes, the request body on the plug side looks like this:

%{
    "0" => %{"file" => "...", "name" => "...", "date" => "..."},
    "1" => %{"file" => "...", "name" => "...", "date" => "..."}
}

and the server replies this:

{"errors":[{"title":"Invalid value","source":{"pointer":"/files"},"detail":"Invalid array. Got: object"}]}

The problems seems that Plug (or whatever) cannot understand that it's an array and not an object with indexes as keys...

mbuhot commented 2 weeks ago

Thanks @neslinesli93 this does appear to be a limitation of Plug. The tests for Plug.Parsers show multiple files can be uploaded as long as they have different parameter names (https://github.com/elixir-plug/plug/blob/9f67dd924c2809ab07b4a798a3c3e718c9da4107/test/plug/parsers_test.exs#L213)

You could try changing the array to an object schema with additionalProperties for allowing any keys:

    OpenApiSpex.schema(%{
      description: "A file with content and metadata",
      type: :object,
      additionalProperties: %Schema{
        type: :object,
        properties: %{
          file: %Schema{
            type: :string,
            format: :binary
          },
          name: %Schema{
            type: :string
          },
          date: %Schema{
            type: :string,
            format: :date
          }
        },
        required: [:file, :name, :date]
      }
    }, struct?: false)

Then upload with:

curl -X 'POST' -v \
  'http://localhost:4002/files' \
  -H 'Content-Type: multipart/form-data' \
  -F 'file_0[file]=@file1.jpg;type=image/jpeg;filename=test1' \
  -F 'file_0[name]=Name1' \
  -F 'file_0[date]=2024-01-01' \
  -F 'file_1[file]=@file2.png;type=image/png;filename=test2' \
  -F 'file_1[name]=Name2' \
  -F 'file_1[date]=2024-02-01' \
neslinesli93 commented 2 weeks ago

It worked amazingly, thank you so much! If you need any help (a PR to clarify this behavior, or add an example, whatever) please just ask. I'm closing the issue anyway, thank you again