OpenApiSpex - OpenAPI / Swagger 3.0 for Plug APIs

Feel free to try out the swaggerui-config branch: https://github.com/open-api-spex/open_api_spex/pull/271

You can configure the supportedSubmitMethods (any many other options) like:

get "/swaggerui", OpenApiSpex.Plug.SwaggerUI,
      path: "/api/openapi",
      supported_submit_methods: [:get, :post],
      default_model_expand_depth: 3,
      display_operation_id: true

Thanks a lot for the latest release and also for the whole library. It’s really useful for me.

One thing I am not sure how to do is something like “polymorphic” schemas. This could be used for example with an endpoint returning 409 (conflict) with multiple meanings.
For example for one endpoint it means email is already registered or email is already invited. For some other endpoint 409 could mean some other options. I know I can create new Error.Conflict* schema for each endpoint and use the enum: [ ... ] atribute, but this could become quite non-DRY after a while.

I would really like something like

409 => {"Conflict", "application/json", Api.Schema.Conflict.new("email_already_registered", "email_already_used")

with the schema defined like this:

defmodule Api.Schema.Conflict do
  require OpenApiSpex
  alias OpenApiSpex.Schema

  def new(enum) do
    OpenApiSpex.schema(%{
      title: "Error.Conflict",
      type: :object,
      properties: %{
        error: %Schema{
          type: :object,
          properties: %{
            name: %Schema{
              type: :string,
              enum: enum,
              required: true
            },
            message: %Schema{type: :string}
          }
        }
      }
    })
  end
end

Is it possible or am trying to use a completely wrong approach? :slight_smile:

Interesting! How about if you construct the %Schema{} struct directly in new?

defmodule Api.Schema.Conflict do
  require OpenApiSpex
  alias OpenApiSpex.Schema

  def new(enum) do
    %Schema{
      title: "Error.Conflict",
      type: :object,
      properties: %{
        error: %Schema{
          type: :object,
          properties: %{
            name: %Schema{
              type: :string,
              enum: enum,
              required: true
            },
            message: %Schema{type: :string}
          }
        }
      }
    }
  end
end

It won’t define a struct for you, but it might be fine for a simple response schema?

Thanks. That works for generating the OpenAPI and Swagger UI, but not when I am trying to use the assert_schema function, since that Conflict schema doesn’t really exist as a struct. I am going to stick to the non-DRY approach.

OK, I ended up somewhere in-between. File for each response, but at least the function/macro is in one place.
Thanks for pointing me to the correct direction.

defmodule Api.Schema.Error do
  @moduledoc """
  ## Usage:

      defmodule Your.Api.Error do
        use Api.Schema.Error,
          title: "Your.Api.Error",
          enum: ["possible", "error", "names"]
      end
  """
  defmacro __using__(opts) do
    title = Keyword.fetch!(opts, :title)
    enum = Keyword.fetch!(opts, :enum)

    quote do
      require OpenApiSpex
      alias OpenApiSpex.Schema

      OpenApiSpex.schema(%{
        title: unquote(title),
        type: :object,
        required: [:error],
        properties: %{
          error: %Schema{
            type: :object,
            required: [:name],
            properties: %{
              name: %Schema{
                type: :string,
                enum: unquote(enum)
              },
              message: %Schema{type: :string}
            }
          }
        }
      })
    end
  end
end

Nice! I think we could do something similar for JSON:API wrapper schemas, where paginated responses all have a similar outer envelope, but different data inside. :+1:

1 Like

I am new to OpenApiSpex librtary and I am trying to figure out how I would specify the doc tags for a multipart form and comming up dry on documentation. Essentially what I want is this example from OpenAPI

          requestBody:
            content:
              multipart/form-data:
                schema:
                  type: object
                  properties:
                    orderId:
                      type: integer
                    userId:
                      type: integer
                    fileName:
                      type: string
                      format: binary

RE: https://swagger.io/docs/specification/describing-request-body/file-upload/

Is there a way to do this? I havent been able to find it.

@rsimmonsjr yes, please see https://github.com/open-api-spex/open_api_spex/issues/253#issuecomment-663289381

Define the schema:


defmodule Schemas.UserMultipartRequest do
    OpenApiSpex.schema(%{
      type: :object,
      # Request parts
      properties: %{
        # Part 1 (string value)
        id: %Schema{type: :string, format: :uuid},
        # Part2 (object)
        address: %Schema{
          type: :object,
          properties: %{
            street: %Schema{type: :string},
            city: %Schema{type: :string}
          }
        },
        # Part 3 (an image)
        profileImage: %Schema{
          type: :string,
          format: :binary
        }
      }
    })
  end

Then use it to describe an operation request body:


@doc parameters: [group_id: [in: :path, type: :integer, description: "Group ID", example: 1]],
  request_body: {"The user attributes", "multipart/form-data", Schemas.UserMultipartRequest, required: true},
  responses: [created: {"User", "application/json", Schemas.UserResponse}]
  def create(conn = %{body_params: body = %Schemas.UserMultipartRequest{}}, %{
        group_id: _group_id
      }) do
  end

Awesome, thanks for the alacrity in response. I will try this out.

Is it possible to do the request body schema inline with the @doc tags? Since its a form of only one element that would be optimal rather than declaring a whole schema for it. In fact, in general I would prefer to put schemas inline on requests rather than declare a schema unless that schema is re-used somehow.

Yes, the request_body @doc tag eventually passes the elements of the tuple through to the Operation.request_body/4 function, which accepts a schema module name, schema reference, or schema struct.

I am having some weird dialyzer errors following the examples in the deocumentation:

  defmodule ChangeAvatarRequest do
    @moduledoc "Schema for requests to change a user's avatar."
    require OpenApiSpex

    OpenApiSpex.schema(%{
      type: :object,
      properties: %{
        avatar: %Schema{
          type: :string,
          format: :binary
        }
      }
    })
  end

  @doc "Upload a new profile image."
  @doc request_body:
         {"Image Upload Form", "multipart/form-data", __MODULE__.ChangeAvatarRequest,
          required: true},
       responses: %{
         200 => {"Avatar URL", "application/json", %Schema{type: :string}},
         422 => OpenApiSpex.JsonErrorResponse.response()
       }
  def upload_avatar(conn = %{body_params: %ChangeAvatarRequest{} = request}, _params) do
    IO.inspect(request, label: "===========> ")
    # FIXME Remove the hardcoded uuid.
    user_uuid = "dccc5004-24db-4eec-b0f9-ffe9be0fad8c"
    # FIXME This construct is brittle if the user_uuid is invalid
    {:ok, pid} = UserDCSP.find_or_start(user_uuid)

    upload_info = %{
      content_type: request.avatar.content_type,
      filename: request.avatar.filename,
      path: request.avatar.path
    }

    case UserDCSP.change_avatar(pid, upload_info) do
      {:ok, avatar_url} -> render(conn, "ok.json", response: avatar_url)
      {:error, e} -> render(conn, "error.json", reason: e)
    end
  end

Gives

lib/xsiom_vue/controllers/user_controller.ex:114:call
The function call will not succeed.

Phoenix.Controller.render(
  _conn :: %{:body_params => %XsiomVue.UserController.ChangeAvatarRequest{_ => _}, _ => _},
  <<101, 114, 114, 111, 114, 46, 106, 115, 111, 110>>,
  [{:reason, _}, ...]
)

will never return since the success typing is:
(
  %Plug.Conn{
    ...
    :body_params => %Plug.Conn.Unfetched{:aspect => atom(), binary() => _},
    ...
  },
  atom() | binary(),
  atom() | binary() | [{_, _}] | map()
) :: %Plug.Conn{
    ...
  :body_params => %Plug.Conn.Unfetched{:aspect => atom(), binary() => _},
    ...
}

and the contract is
(Plug.Conn.t(), binary() | atom(), Keyword.t() | map() | binary() | atom()) ::
  Plug.Conn.t()

I can make the error go away by not putting it in the match line but then it fails. The code works but dialyzer is very mad.

@rsimmonsjr Yeah unfortunately when we swap body_params on the conn with structs it breaks the typespecs.

There’s an issue in the Github repo with a workaround to silence dialyzer, but I think we’ll need to release a breaking change eventually to store the converted parameters somewhere in Conn.private instead of Conn.body_params.

Bah, Spent hours on this.

I think the fix for putting the request in the private data should be implemented as soon as possible. I like using dialyzer to check my code but spurious false errors are problematic since now I have them all over the place.

As it will require a breaking change to the API it won’t be updated until the next major version.

There is a way to easily work around this problem:

# Get body params
def create(conn, _params) do
  # Dialyzer misses the fact that you are accessing body_params
  body_params = Map.get(conn, :body_params)
  # You are now free to reference compile-time atom keys from body params
  %{user: user_params} = body_params
end

# Get other params
def create(conn, params) do
  # Dialyzer misses the fact that you are accessing a param value using an atom key
  id = Map.get(params, :id)
end

This solution is now added to the Github issue.

1 Like

Well it doesn’t allow the check and validation which I like so its not a complete “solution” but rather a compromise that ditches functionality to silence dialyzer.

Well it doesn’t allow the check and validation which I like so its not a complete “solution” but rather a compromise that ditches functionality to silence dialyzer.

That’s not true. This solution works with the CastAndValidate plug and its functionality. Here’s a more complete example showing that CastAndValidate is being used:

defmodule MyAppWeb.MyController do
  use MyAppWeb, :controller
  use OpenApiSpex.Controller

  plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true

  # Use body params
  def create(conn, _params) do
    # Dialyzer misses the fact that body_params is being accessed
    body_params = Map.get(conn, :body_params)
    # The code is now free to reference compile-time atom keys from body params, without complaint from Dialyzer
    %{user: user_params} = body_params
  end

  # Use other params
  def create(conn, params) do
    # Dialyzer misses the fact that a param value is being accessed using an atom key
    id = Map.get(params, :id)
  end
end

I don’t know how that could possibly work. Does the plug intercept the function before it is called maybe? I though CastAndValidate would be called when we tried to cast the type which you aren’t even doing here.