Git Product home page Git Product logo

open_api_spex's Introduction

Open API Spex

Elixir CI Module Version Hex Docs Total Download License Last Updated

Leverage Open API Specification 3 (formerly Swagger) to document, test, validate and explore your Plug and Phoenix APIs.

  • Generate and serve a JSON Open API Spec document from your code
  • Use the spec to cast request params to well defined schema structs
  • Validate params against schemas, eliminate bad requests before they hit your controllers
  • Validate responses against schemas in tests, ensuring your docs are accurate and reliable
  • Explore the API interactively with SwaggerUI

Full documentation available on HexDocs.

Installation

The package can be installed by adding :open_api_spex to your list of dependencies in mix.exs:

def deps do
  [
    {:open_api_spex, "~> 3.18"}
  ]
end

Generate Spec

Main Spec

Start by adding an ApiSpec module to your application to populate an OpenApiSpex.OpenApi struct.

defmodule MyAppWeb.ApiSpec do
  alias OpenApiSpex.{Components, Info, OpenApi, Paths, Server}
  alias MyAppWeb.{Endpoint, Router}
  @behaviour OpenApi

  @impl OpenApi
  def spec do
    %OpenApi{
      servers: [
        # Populate the Server info from a phoenix endpoint
        Server.from_endpoint(Endpoint)
      ],
      info: %Info{
        title: "My App",
        version: "1.0"
      },
      # Populate the paths from a phoenix router
      paths: Paths.from_router(Router)
    }
    |> OpenApiSpex.resolve_schema_modules() # Discover request/response schemas from path specs
  end
end

Or you can use your application's spec values in the info: key.

info: %Info{
  title: to_string(Application.spec(:my_app, :description)),
  version: to_string(Application.spec(:my_app, :vsn))
}

Authorization

In case your API requires authorization you can add security schemes as part of the components in the main spec.

components: %Components{
  securitySchemes: %{"authorization" => %SecurityScheme{type: "http", scheme: "bearer"}}
}

Once the security scheme is defined you can declare it. Please note that the key below matches the one defined in the security scheme, in the our example, "authorization".

security: [%{"authorization" => []}]

If you require authorization for all endpoints you can declare the security in the main spec. In case you need authorization only for specific endpoints, or if you are using more than one security scheme, you can declare it as part of each operation.

To learn more about the different security schemes please the check the official documentation.

Operations

For each plug (controller) that will handle API requests, operations need to be defined that the plug/controller will handle.

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use OpenApiSpex.ControllerSpecs

  alias MyAppWeb.Schemas.{UserParams, UserResponse}

  tags ["users"]
  security [%{}, %{"petstore_auth" => ["write:users", "read:users"]}]

  operation :update,
    summary: "Update user",
    parameters: [
      id: [in: :path, description: "User ID", type: :integer, example: 1001]
    ],
    request_body: {"User params", "application/json", UserParams},
    responses: [
      ok: {"User response", "application/json", UserResponse}
    ]

  def update(conn, %{"id" => id}) do
    json(conn, %{
      data: %{
        id: id,
        name: "joe user",
        email: "[email protected]"
      }
    })
  end
end

Note: In order to prevent Elixir Formatter from automatically adding parentheses to the ControllerSpecs macro call arguments, add :open_api_spex to the import_deps list in .formatter.exs:

.formatter.exs:

[
  import_deps: [:open_api_spex]
]

For further information about defining operations, see OpenApiSpex.ControllerSpecs.

If you need to omit the spec for some action then pass false to the second argument of operation/2 for the action:

operation :create, false

Each definition in a controller action or plug operation is converted to an %OpenApiSpex.Operation{} struct. The definitions are read by your application's ApiSpec module, which in turn is called from the OpenApiSpex.Plug.PutApiSpex plug on each request. The definitions data is cached, so it does not actually extract the definitions on each request.

Note that the names of the OpenAPI fields follow snake_case naming convention instead of OpenAPI's (and JSON Schema's) camelCase convention.

Alternatives to ControllerSpecs-style Operation Specs

%Operation{}

If ControllerSpecs-style operation specs don't provide the flexibility you need, the %Operation{} struct and related structs can be used instead. See the example user controller that uses %Operation{} structs.

For examples of other action operations, see the example web app.

Schemas

Next, declare JSON schema modules for the request and response bodies. In each schema module, call OpenApiSpex.schema/1, passing the schema definition. The schema must have keys described in OpenApiSpex.Schema.t. This will define a %OpenApiSpex.Schema{} struct. This struct is made available from the schema/0 public function, which is generated by OpenApiSpex.schema/1.

You may optionally have the data described by the schema turned into a struct linked to the JSON schema by adding "x-struct": __MODULE__ to the schema.

defmodule MyAppWeb.Schemas do
  alias OpenApiSpex.Schema

  defmodule User do
    require OpenApiSpex

    OpenApiSpex.schema(%{
      # The title is optional. It defaults to the last section of the module name.
      # So the derived title for MyApp.User is "User".
      title: "User",
      description: "A user of the app",
      type: :object,
      properties: %{
        id: %Schema{type: :integer, description: "User ID"},
        name: %Schema{type: :string, description: "User name", pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/},
        email: %Schema{type: :string, description: "Email address", format: :email},
        birthday: %Schema{type: :string, description: "Birth date", format: :date},
        inserted_at: %Schema{
          type: :string,
          description: "Creation timestamp",
          format: :"date-time"
        },
        updated_at: %Schema{type: :string, description: "Update timestamp", format: :"date-time"}
      },
      required: [:name, :email],
      example: %{
        "id" => 123,
        "name" => "Joe User",
        "email" => "[email protected]",
        "birthday" => "1970-01-01T12:34:55Z",
        "inserted_at" => "2017-09-12T12:34:55Z",
        "updated_at" => "2017-09-13T10:11:12Z"
      }
    })
  end

  defmodule UserResponse do
    require OpenApiSpex

    OpenApiSpex.schema(%{
      title: "UserResponse",
      description: "Response schema for single user",
      type: :object,
      properties: %{
        data: User
      },
      example: %{
        "data" => %{
          "id" => 123,
          "name" => "Joe User",
          "email" => "[email protected]",
          "birthday" => "1970-01-01T12:34:55Z",
          "inserted_at" => "2017-09-12T12:34:55Z",
          "updated_at" => "2017-09-13T10:11:12Z"
        }
      }
    })
  end
end

For more examples of schema definitions, see the sample Phoenix app.

Serve the Spec

To serve the API spec from your application, first add the OpenApiSpex.Plug.PutApiSpec plug somewhere in the pipeline.

pipeline :api do
  plug OpenApiSpex.Plug.PutApiSpec, module: MyAppWeb.ApiSpec
end

Now the spec will be available for use in downstream plugs. The OpenApiSpex.Plug.RenderSpec plug will render the spec as JSON:

scope "/api" do
  pipe_through :api
  resources "/users", MyAppWeb.UserController, only: [:create, :index, :show]
  get "/openapi", OpenApiSpex.Plug.RenderSpec, []
end

In development, to ensure the rendered spec is refreshed, you should disable caching with:

# config/dev.exs
config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache

Generating the Spec

You can write the swagger file to disk using the following Mix task and optionally, for your convenience, create a direct alias:

mix openapi.spec.json --spec MyAppWeb.ApiSpec
mix openapi.spec.yaml --spec MyAppWeb.ApiSpec

Invoking this task starts the application by default. This can be disabled with the --start-app=false option.

Please replace any calls to OpenApiSpex.Server.from_endpoint with a %OpenApiSpex.Server{} struct like below:

  %OpenApi{
    info: %Info{
      title: "Phoenix App",
      version: "1.0"
    },
    # Replace this ๐Ÿ‘‡
    servers: [OpenApiSpex.Server.from_endpoint(MyAppWeb.Endpoint)],
    # With this ๐Ÿ‘‡
    servers: [%OpenApiSpex.Server{url: "https://yourapi.example.com"}],
  }

NOTE: You need to add the ymlr dependency to write swagger file in YAML format:

def deps do
  [
    {:ymlr, "~> 2.0"}
  ]
end

For more options read the docs.

mix help openapi.spec.json
mix help openapi.spec.yaml

Serve Swagger UI

Once your API spec is available through a route (see "Serve the Spec"), the OpenApiSpex.Plug.SwaggerUI plug can be used to serve a SwaggerUI interface. The path: plug option must be supplied to give the path to the API spec.

All JavaScript and CSS assets are sourced from cdnjs.cloudflare.com, rather than vendoring into this package.

scope "/" do
  pipe_through :browser # Use the default browser stack

  get "/", MyAppWeb.PageController, :index
  get "/swaggerui", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi"
end

scope "/api" do
  pipe_through :api

  resources "/users", MyAppWeb.UserController, only: [:create, :index, :show]
  get "/openapi", OpenApiSpex.Plug.RenderSpec, []
end

Importing an existing schema file

โš ๏ธ This functionality currently converts Strings into Atoms, which makes it potentially vulnerable to DoS attacks. We recommend that you load Open API Schemas from known files during application startup and not dynamically from external sources at runtime.

OpenApiSpex has functionality to import an existing schema, casting it into an %OpenApi{} struct. This means you can load a schema that is JSON or YAML encoded. See the example below:

# Importing an existing JSON encoded schema
open_api_spec_from_json = "encoded_schema.json"
  |> File.read!()
  |> Jason.decode!()
  |> OpenApiSpex.OpenApi.Decode.decode()

# Importing an existing YAML encoded schema
open_api_spec_from_yaml = "encoded_schema.yaml"
  |> YamlElixir.read_all_from_file!()
  |> List.first()
  |> OpenApiSpex.OpenApi.Decode.decode()

You can then use the loaded spec to with OpenApiSpex.cast_and_validate/3, like:

{:ok, _} = OpenApiSpex.cast_and_validate(
  open_api_spec_from_json, # or open_api_spec_from_yaml
  spec.paths["/some_path"].post,
  test_conn
)

Validating and Casting Params

OpenApiSpex can automatically validate requests before they reach the controller action function. Or if you prefer, you can explicitly call on OpenApiSpex to cast and validate the params within the controller action. See OpenApiSpex.cast_value/3 for the latter.

The rest of this section describes implicit casting and validating the request before it reaches your controller action.

First, the plug OpenApiSpex.Plug.PutApiSpec needs to be called in the Router, as described above.

Add the OpenApiSpex.Plug.CastAndValidate plug to a controller to validate request parameters and to cast to Elixir types defined by the operation schema.

# Phoenix
plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true
# Plug
plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true, operation_id: "UserController.create"

The json_render_error_v2: true is a work-around for a bug in the format of the default error renderer. It will be not needed in version 4.0.

For Phoenix apps, the operation_id can be inferred from the contents of conn.private.

The data shape of the default error renderer follows the JSON:API spec for error responses. For convenience, the OpenApiSpex.JsonErrorResponse schema module is available that specifies the shape, and it can be used in your API specs.

Example usage of CastAndValidate in a Phoenix controller:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use OpenApiSpex.ControllerSpecs

  alias MyAppWeb.Schemas.{UserParams, UserResponse}

  plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true

  operation :update,
    summary: "Update user",
    description: "Updates with the given params.\nThis is another line of text in the description.",
    parameters: [
      id: [in: :path, type: :integer, description: "user ID"],
      vsn: [in: :query, type: :integer, description: "API version number"],
      "api-version": [in: :header, type: :integer, description: "API version number"]
    ],
    request_body: {"The user attributes", "application/json", UserParams},
    responses: %{
      201 => {"User", "application/json", UserResponse},
      422 => OpenApiSpex.JsonErrorResponse.response()
    }
  def update(
        conn = %{
          body_params: %UserParams{
            name: name,
            email: email,
            birthday: %Date{} = birthday
          }
        },
        %{id: id}
      ) do
    # conn.body_params cast to UserRequest struct
    # conn.params combines path params, query params and header params
    # conn.params.id cast to integer
    # conn.params.vsn cast to integer
    # conn.params[:"api-version"] cast to integer
    # params is the same as conn.params
    # params.id cast to integer

    # Note: Using pattern-matching in the action function's arguments can
    # cause Dialyzer to complain. This is because Dialyzer expects the
    # `conn` and `params` arguments to have string keys, not atom keys.
    # To resolve this, fetch the `:body_params` with
    # `body_params = Map.get(conn, :body_params)`.
  end
end

Now the client will receive a 422 response whenever the request fails to meet the validation rules from the api spec.

The response body will include the validation error message:

{
  "errors": [
    {
      "detail": "Invalid format. Expected :date",
      "source": {
        "pointer": "/data/birthday"
      },
      "title": "Invalid value"
    }
  ]
}

If you would like a different response JSON shape, create a plug module to shape the response, and pass it to CastAndValidate:

plug OpenApiSpex.Plug.CastAndValidate, render_error: MyErrorRendererPlug
defmodule MyErrorRendererPlug do
  @behaviour Plug

  alias Plug.Conn
  alias OpenApiSpex.OpenApi

  @impl Plug
  def init(errors), do: errors

  @impl Plug
  def call(conn, errors) when is_list(errors) do
    response = %{
      errors: Enum.map(errors, &to_string/1)
    }

    json = OpenApi.json_encoder().encode!(response)

    conn
    |> Conn.put_resp_content_type("application/json")
    |> Conn.send_resp(422, json)
  end
end

Generate Examples

OpenApiSpex can generate example data from specs. This has a similar result as SwaggerUI when it generates example requests or responses for an endpoint. This is a convenient way to generate test data for controller/plug tests.

use MyAppWeb.ConnCase

test "create/2", %{conn: conn} do
  request_body = OpenApiSpex.Schema.example(MyAppWeb.Schemas.UserRequest.schema())

  json =
    conn
    |> post(user_path(conn), request_body)
    |> json_response(200)
end

Validate Examples

As schemas evolve, you may want to confirm that the examples given match the schemas. Use the OpenApiSpex.TestAssertions module to assert on schema validations.

use ExUnit.Case
import OpenApiSpex.TestAssertions

test "UsersResponse example matches schema" do
  api_spec = MyAppWeb.ApiSpec.spec()
  schema = MyAppWeb.Schemas.UsersResponse.schema()
  assert_schema(schema.example, "UsersResponse", api_spec)
end

Validate Responses

API responses can be tested against schemas using OpenApiSpex.TestAssertions also:

use MyAppWeb.ConnCase
import OpenApiSpex.TestAssertions

test "UserController produces a UsersResponse", %{conn: conn} do
  json =
    conn
    |> get(user_path(conn, :index))
    |> json_response(200)

  api_spec = MyAppWeb.ApiSpec.spec()
  assert_schema(json, "UsersResponse", api_spec)
end

Copyright and License

Copyright (c) 2017 Michael Buhot

Licensed under the Mozilla Public License, Version 2.0, which can be found in LICENSE.

open_api_spex's People

Contributors

aisrael avatar albertored avatar brentjr avatar bryannaegele avatar feng19 avatar fenollp avatar ggpasqualino avatar holsee avatar lucacorti avatar mbuhot avatar mojidabckuu avatar moxley avatar mrmstn avatar msutkowski avatar nurugger07 avatar oliver-schoenherr avatar palcalde avatar shikanime avatar slapers avatar slavo2 avatar supermaciz avatar surik avatar tapickell avatar tehprofessor avatar vovayartsev avatar wingyplus avatar xadhoom avatar zakjholt avatar zorbash avatar zoten avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

open_api_spex's Issues

Better validation error format

When we get a validation error in the fallback controller we receive a string in the body for the error.
This string is hard to parse and we have started down the path of trying to parse these strings to get the path of the invalid property in order to produce proper json api error responses that are meaningful to our API consumers.
I would like to propose returning something easier to work with within the code.
Perhaps returning a Map with some keys we can get the info from without parsing strings.

Examples:
(Current Error)
"#/data: Missing required properties: [:relationships]"
could be something like

%{
  path: ["data", "relationships"],
  message: "Missing required properties"
}

and
(Current Error)
"#/data/attributes/resource: null value where string expected"
could be something like

%{
  path: ["data", "attributes", "resource"],
  message: "null value where string expected"
}

We should have a way to represent paths that have arrays in them as well
"#/data/0/id: null value where string expected"

%{
  path: ["data", 0, "id"],
  message: "null value where string expected"
}

We would be happy to create a PR for this, we wanted to propose the idea and get some feedback before going forward with this approach.
We also may be interested in maintaining this project as well as it looks like we will be using it extensively for our applications.
https://github.com/GhostGroup
Thank you

Exposing Swagger endpoint raises Dialyzer issues

If I expose the swagger endpoint in my Phoenix router with:

    scope "/" do
      _ = get("/swagger", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi")
    end

I get the error

    unmatched_return
    Expression produces a value of type:

    %{:path => _}

    but this value is unmatched.

I'm assuming the issue isn't actually in the router, but the module itself. Is that right?

Validate without casting to struct

There is no way to validate requests without also casting the request to a struct, as opposed to a simple map. We haven't found any benefit of receiving structs-- only drawbacks.

The two drawbacks we have found are:

  1. In certain situations, we have to call Map.from_struct/1 on the casted struct, because the __struct__ key interferes with what we're trying to do.
  2. When an object has optional properties, they are always present in the output of the cast function, even when they're absent in the input. This is because the output is a struct, and structs always have all the keys in their definitions. Sometimes, this causes undesirable results, and extra care has to be take to work around these always-present keys.

Would anyone please list specific benefits of returning structs for the cast operation?

Validation of DeepObject properties in query

Hi Mike

Lets say one have a following query

/books?filter[name]=Encyclopedia&filter[edition]=2&filter[published]=2018-01-01

i.e. there is a "deepObject" (https://swagger.io/docs/specification/serialization/) in the query,
called filter, which can (but does not have to) contain properties with specific format (string, number, date).

How one could make an operation spec, using open_api_spex,
so that OpenApiSpex.Plug.Validate would properly validate its parts?

Thanks

Slavo

Allow customising JSON encoders

Currently the community is mostly migrating to jason library due to it's performance benefits. It would be worth making JSON encoder configurable so when in need someone could change it to fit their own preferences and/or needs (for example to use streaming parser instead).

Possible improvement to obtaining url from Endpoint for Server Spec

@doc """
Builds a Server from a phoenix Endpoint module
"""
@spec from_endpoint(module, [otp_app: atom]) :: t
def from_endpoint(endpoint, opts) do
app = opts[:otp_app]
url_config = Application.get_env(app, endpoint, []) |> Keyword.get(:url, [])
scheme = Keyword.get(url_config, :scheme, "http")
host = Keyword.get(url_config, :host, "localhost")
port = Keyword.get(url_config, :port, "80")
path = Keyword.get(url_config, :path, "/")
%Server{
url: "#{scheme}://#{host}:#{port}#{path}"
}
end

I feel this can be better achieved through:

%Server{
  url: MyApp.Endpoint.url()
}

Need more robust "type" arg validation in OpenApiSpex.schema macro

It's too easy to make silly mistakes in OpenApiSpex.schema macro :-)
My mistake was omitting type field of a regular (non-polymorphic) schema.

    OpenApiSpex.schema(
      %{
        title: "Response",
        # type: :object" <-- it's missing here
        properties: %{
          page: %Schema{type: :integer, required: true},
          hitsPerPage: %Schema{type: :integer},
          ....
        }
      }

As a result, testing the response structure in my test suite didn't really check the properties

      # this always succeeded
      assert_schema(response, "Response", api_spec)

I guess the validation was halting at this code in schema.ex

  def validate(%Schema{type: nil}, _value, _path, _schemas) do
    # polymorphic schemas will terminate here after validating against anyOf/oneOf/allOf/not
    :ok
  end

What do you think about adding some checks to OpenApiSpex.schema macro to avoid at least this particular silly but dangerous mistake?

raise exception on invalid query parameter

Hi @mbuhot, if an undefined query parameter is transmitted the cast plug (and also the validate plug) is just ignoring this parameter. I think it would be better for the api user to respond with an undefined parameter error. Otherwise it is hard for the consumer to debug unexpected behavior.

What do you think?

Cheers.

cast does not "atomify" keys in maps of enum payloads

From #42

enums validation: cast does not "atomify" keys in maps of enum payloads. In other words, the following code does not crash:

schema = %Schema{enum: [%{id: 42}]}
spec = ...
# string keys
{:ok, data} = OpenApiSpex.cast(spec, schema, %{"id" => 42})
{:error, _} = OpenApiSpex.validate(spec, schema, data)
# atom keys
{:ok, data} = OpenApiSpex.cast(spec, schema, %{id: 42})
:ok = OpenApiSpex.validate(spec, schema, data)

I think it would be very dangerous (OOM) to atomify enum keys anyway!
My current workaround is to specify enum schemas with string keys instead, which can be easily missed by anyone.

So how about throwing an error when creating a schema that has atom keys in enum: ...?

Casted conn.params and conn.body_params do not match type spec. Dialyzer raises warnings.

The type spec for params and body_params is params() :: %{required(binary()) => param()} (https://hexdocs.pm/plug/1.7.2/Plug.Conn.html#t:params/0), but OpenApiSpex turns them into something like %{__struct__: module(), required(atom()) => term()}, or something else that doesn't match the spec. This causes dialyzer complaints in the controller actions for some situations.

IMO, OpenApiSpex shouldn't change conn.params or conn.body_params, as it breaks the contract with Plug. Instead, Spex can put the casted values in the :private attribute of the %Conn{}.

resolve_schema_modules/1 do not resolve nested schemas

When I define result schema as:

Operation.response(
  "Submissions",
  "application/json",
  %OpenApiSpex.Schema{
    title: "Submissions",
    description: "List of submissions",
    type: :array,
    items: KokonWeb.Rest.Schema.Submission
  }
)

Then in the the output it is defined as:

{
  "description": "Submissions",
  "content": {
    "application/json": {
      "schema": {
        "type": "array",
        "title": "Submissions",
        "items": "Elixir.KokonWeb.Rest.Schema.Submission",
        "description": "List of submissions"
      }
    }
  }
}

Where I would expect items to be reference to Submission definition.

Missing path parameters go unnoticed

Using https://editor.swagger.io/ I just caught a few errors that so far had gone unnoticed. I had a few routes with path parameters (e.g. get("/dota2/abilities/:dota2_ability_id", Dota2.AbilityController, :show)) but I forgot to declare :dota2_ability_id in the parameters list. Here it is, commented out:

  @spec open_api_operation(atom()) :: Op.t()
  # /dota2/abilities/:dota2_ability_id
  def open_api_operation(:show),
    do: %Op{
    # parameters: [OpenAPI.param(:dota2_ability_id)],
      responses: OpenAPI.resp_Dota2Ability()
    }

Could we add some check that makes the ....OpenAPI.spec() call fail if path parameters are missing?
I'm not sure what could be done for missing parameters of other kinds.

Response Serialization

Instead of manually implementing endpoint response serialization, why not leverage OpenApiSpex to do it? We've been using ja_serializer for our json:api endpoints, but it's kind of a pain, and it duplicates what's already in our OpenApiSpex specifications.

Fails to generate

I can view the Swagger just fine, but when I come to generate it, I get

** (ArgumentError) argument error
    (stdlib) :ets.lookup(EmbedWeb.Endpoint, :__phoenix_struct_url__)
    lib/phoenix/config.ex:45: Phoenix.Config.cache/3
    (open_api_spex) lib/open_api_spex/server.ex:39: OpenApiSpex.Server.from_endpoint/1
    lib/embed/api_spec.ex:9: Embed.V1.ApiSpec.spec/0
    lib/embed/api_task.ex:6: Mix.Tasks.Embed.OpenApiSpec.run/1
    (mix) lib/mix/task.ex:331: Mix.Task.run_task/3
    (mix) lib/mix/cli.ex:79: Mix.CLI.run_task/2

I'm not sure what's missing. The Spec and Task modules are created correctly.

Potentially unsafe atom generation used in documentation

Within the docs the open_api_operation/1 function creates an atom :"#{action}_operation" which is invoked during the request execution.

This is a potential exploit vector (as atoms are not gc'd)

Sobelow highlighted this as follows:

##############################################
#                                            #
#          Running Sobelow - v0.7.7          #
#  Created by Griffin Byatt - @griffinbyatt  #
#     NCC Group - https://nccgroup.trust     #
#                                            #
##############################################

Unsafe atom interpolation - Low Confidence
File: apps/bond_web/lib/bond_web/schemas/integrations.ex - open_api_operation:99
Variable: action

I suggest the documentation is updated to have an example like the following:

    @spec open_api_operation(any) :: Operation.t()
    def open_api_operation(action) do
      operation = String.to_existing_atom("#{action}_operation")
      apply(__MODULE__, operation, [])
    end

Even better would be guidance on what to do if the operation is not defined, maybe a nice error message to aid the developer?

Casting allOf: will result in a :unexpected_field error

Hey There,
It looks like there is an issue with the allOf casting mechanism.

The main issue is, that the caster will return the error :unexpected_field since the payload will mostly provide some files which are not defined in every schema of allOf.

As an example:

schema = %Schema{
  title: "demo",
  type: :object,
  allOf: [
      MyApp.Schemas.GenericRequest,
      %Schema{
          properties: %{
               bar: %Schema{type: :string}
          }
      }
  ]
}

Let's just assume that GenericRequest has something defined like id: %Schema{type: string} and the request looks something like this: {"id": "1231-123155-1231-12312", "bar": "whatever"}

Based on https://github.com/open-api-spex/open_api_spex/blob/master/lib/open_api_spex/cast/all_of.ex#L8, the caster will first try to cast everything from the request on the first schema, but since I've only defined the id and not the bar in the first one, this will always result in a :unexpected_field error

Cut a release

@mbuhot could we get a release cut and pushed to hex? There's been a lot of updates and fixes since the last release at the end of October.

Thanks!

When Content Type Header Missing`** (UndefinedFunctionError) function nil.schema/0 is undefined`

I am seeing the following error when testing a controller action (with the stock Phoenix ConnCase style tests):

  1) test POST /api/v0/integrations/register produces a RegistrationSuccessfulResponse (BondWeb.IntegrationControllerTest)
     test/bond_web/controllers/integration_controller_test.exs:26
     ** (UndefinedFunctionError) function nil.schema/0 is undefined
     code: |> post(integration_path(conn, :register), registration_args)
     stacktrace:
       nil.schema()
       (open_api_spex) lib/open_api_spex/operation2.ex:35: OpenApiSpex.Operation2.cast_request_body/4
       (open_api_spex) lib/open_api_spex/operation2.ex:21: OpenApiSpex.Operation2.cast/4
       (open_api_spex) lib/open_api_spex/plug/cast_and_validate.ex:70: OpenApiSpex.Plug.CastAndValidate.call/2
       (bond_web) lib/bond_web/controllers/integration_controller.ex:1: BondWeb.IntegrationController.phoenix_controller_pipeline/2
       (bond_web) lib/bond_web/endpoint.ex:1: BondWeb.Endpoint.instrument/4
       (phoenix) lib/phoenix/router.ex:275: Phoenix.Router.__call__/1
       (bond_web) lib/bond_web/endpoint.ex:1: BondWeb.Endpoint.plug_builder_call/2
       (bond_web) lib/bond_web/endpoint.ex:1: BondWeb.Endpoint.call/2
       (phoenix) lib/phoenix/test/conn_test.ex:235: Phoenix.ConnTest.dispatch/5
       test/bond_web/controllers/integration_controller_test.exs:39: (test)

I can see that that the error is happening within the plug OpenApiSpex.Plug.CastAndValidate however when I call the API when the Phoenix server is running, this plug does not fail and the Casting and Validation works fine.

This leads me to conclude that something is not happening (possibly some information in conn?) in test that is present during development execution.

This may not be a bug, but it does seem that there is an initialisation step needed in testing to avoid this issue?

Appreciate any help in this matter, love the project <3!

Bump version

Hi @mbuhot,
could you pls release the latest patches to a new hex version?
Or are there any metrics on which you decide to do so?

Cheers.

Map-access on schema when using as request body on validation

Hello again @mbuhot ,

we are running into a problem using the the validation plug.

We are defining a schema as request body:

  def mark_as_shipped_operation do
    %Operation{
      ...
      requestBody:
        Operation.request_body(
          "the request body object",
          "application/json",
          Schemas.OrderMarkAsShippedRequest,
          required: false
        ),
      ...
    }
  end

While the validate plug expects a map, it gets a struct in this situation. This leads to the following error, since struct[key] access is not possible on a struct:

test update order valid mark order shipped (UnifysellApiWeb.OrderControllerTest)
     test/unifysell_api_web/controllers/order_controller_test.exs:79
     ** (UndefinedFunctionError) function UnifysellApiWeb.Schemas.OrderMarkAsShippedRequest.fetch/2 is undefined (UnifysellApiWeb.Schemas.OrderMarkAsShippedRequest does not implement the Access behaviour)
     code: conn = put conn, "/api/order/1/mark-as-shipped", ~S({"labelId": 1})
     stacktrace:
       (unifysell_api) UnifysellApiWeb.Schemas.OrderMarkAsShippedRequest.fetch(%{__struct__: UnifysellApiWeb.Schemas.OrderMarkAsShippedRequest, labelId: 1, order_id: 1}, :order_id)
       (elixir) lib/access.ex:308: Access.get/3
       (open_api_spex) lib/open_api_spex/operation.ex:179: OpenApiSpex.Operation.validate_parameter_schemas/3
       (open_api_spex) lib/open_api_spex/operation.ex:156: OpenApiSpex.Operation.validate/4
       (open_api_spex) lib/open_api_spex/plug/validate.ex:33: OpenApiSpex.Plug.Validate.call/2
       (unifysell_api) lib/unifysell_api_web/controllers/order_controller.ex:1: UnifysellApiWeb.OrderController.phoenix_controller_pipeline/2
       (unifysell_api) lib/unifysell_api_web/endpoint.ex:1: UnifysellApiWeb.Endpoint.instrument/4
       (phoenix) lib/phoenix/router.ex:278: Phoenix.Router.__call__/1
       (unifysell_api) lib/unifysell_api_web/endpoint.ex:1: UnifysellApiWeb.Endpoint.plug_builder_call/2
       (unifysell_api) lib/unifysell_api_web/endpoint.ex:1: UnifysellApiWeb.Endpoint.call/2
       (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
       test/unifysell_api_web/controllers/order_controller_test.exs:93: (test)

Updating schema works, but of course is not a good solution, But we get an idea why the error occurs.:

defmodule OrderMarkAsShippedRequest do
    def fetch(a, b) do
      Map.fetch(a, b)
    end

    OpenApiSpex.schema(%{...})
  end

We could update the validate_parameter_schemas(...) function like this:

  @spec validate_parameter_schemas([Parameter.t], map, %{String.t => Schema.t}) :: {:ok, map} | {:error, String.t}
  defp validate_parameter_schemas([], params, _schemas), do: {:ok, params}
  defp validate_parameter_schemas(param_list, %_{} = params, schemas) do
    validate_parameter_schemas(param_list, Map.from_struct(params), schemas)
  end
  defp validate_parameter_schemas([p | rest], %{} = params, schemas) do
    with :ok <- Schema.validate(Parameter.schema(p), params[p.name], schemas),
         {:ok, remaining} <- validate_parameter_schemas(rest, params, schemas) do
      {:ok, Map.delete(remaining, p.name)}
    end
  end

We could also update it like this:

  @spec validate_parameter_schemas([Parameter.t], map, %{String.t => Schema.t}) :: {:ok, map} | {:error, String.t}
  defp validate_parameter_schemas([], params, _schemas), do: {:ok, params}
  defp validate_parameter_schemas([p | rest], %{} = params, schemas) do
    with :ok <- Schema.validate(Parameter.schema(p), Map.fetch(params, p.name), schemas),
         {:ok, remaining} <- validate_parameter_schemas(rest, params, schemas) do
      {:ok, Map.delete(remaining, p.name)}
    end
  end

If one of these solutions is okay to you, just tell me and i will create a pull request. Or maybe we are doing something else wrong?

Greetings, Thomas

Call for maintainers!

I'm no longer using swagger in my day job, so I haven't had the need to make many improvements to this package.

If there are any users who are relying on open_api_spex in commercial / production settings that are willing to share in the maintenance of the project, please respond in the comments, or email me ([email protected]).

I'm happy to move this repo to a Github org and share ownership of the Hex package.

cc: @ThomasRueckert @fenollp @slavo2

JSON Api helpers

Hey there! Love this project! Im currently trying to migrate my teamโ€™s elixir swagger docs from swagger format 2.0 to 3.0. (All of the rest of our teams use OpenApi 3.0 in their Rails apps)

My project also conforms to JSON API Schema. The old tool we were using (phoenix_swagger) had JSONAPI helpers. Ex: https://hexdocs.pm/phoenix_swagger/json-api-helpers.html#content
They donโ€™t support openapi 3.0 though, which is why we want to start using this library. JSONAPI helpers to make writing these Schemas smoother would be a great addition.

Is that something that could be added to this project?

How to use for a pure plug project? (no phoenix)

It's unclear to me if this is supposed to be usable in a pure plug project, which does not use phoenix. Will it work? If so how?

I've tried adapting the examples in the README but unsuccessfully...

How to write callbacks?

I see there is support for defining out of band callbacks which are part of an operation.
https://swagger.io/specification/#callbackObject

I cannot figure out how this should be written using OpenAPISpex as I am running into some strange errors. Can you please provide an example of how callbacks should be written?

I see Operation has a key callbacks, this takes a map of String.t =>PathItem.t.
The PathItem.t has fields which each take an Operation.t.

      callbacks: %{
        "account_link_callback" => %PathItem{
          post: %Operation{
            summary: "Account Link Callback",
            description:
              "Invoked when account link is established or if the account link process fails",
            responses:
              response(
                "List registered integrations response",
                "application/json",
                __MODULE__.ListResponse
              )
          }
        }

This produces:

image

Which from the outset looks OK?
But there is likely an issue as it renders like so:

image

Casting using oneOf not implemented?

Hi there ๐Ÿ‘‹

This probably relates to #23

First off, thanks a lot for all the work to put this library together!
I've had a great time using it to document some Elixir APIs.

Recently I've been trying to invest some effort in building better schemas and let OpenApiSpex.Plug.Cast cast them into structs. However I ran into a few issues when trying to use polymorphic schemas.

For instance if I have the following schemas:

defmodule OpenApiSpexOneOfDemo.PetPlug.PetRequest do
  require OpenApiSpex

  alias OpenApiSpex.Discriminator
  alias OpenApiSpexOneOfDemo.Schemas.{Cat, Dog}

  OpenApiSpex.schema(%{
    title: "PetRequest",
    type: :object,
    oneOf: [Cat, Dog],
    discriminator: %Discriminator{
      propertyName: "pet_type"
    },
    example: %{"pet_type" => "Cat", "meow" => "meow"}
  })
end

defmodule OpenApiSpexOneOfDemo.Schemas do
  defmodule Cat do
    require OpenApiSpex

    alias OpenApiSpex.Schema

    OpenApiSpex.schema(%{
      title: "Cat",
      type: :object,
      properties: %{
        pet_type: %Schema{type: :string},
        meow: %Schema{type: :string}
      },
      required: [:pet_type, :meow]
    })
  end

  defmodule Dog do
    require OpenApiSpex

    alias OpenApiSpex.Schema

    OpenApiSpex.schema(%{
      title: "Dog",
      type: :object,
      properties: %{
        pet_type: %Schema{type: :string},
        bark: %Schema{type: :string}
      },
      required: [:pet_type, :bark]
    })
  end
end

I would expect posting the following JSON:

{
  "pet_type": "Cat",
  "meow": "meow"
}

To be cast into the following struct when using the OpenApiSpex.Plug.Cast plug:

%OpenApiSpexOneOfDemo.Schemas.Cat{meow: "meow", pet_type: "Cat"}

However, in my tests, it ends up being cast into:

%OpenApiSpexOneOfDemo.PetPlug.PetRequest{}

Having gone through the code a bit, it seems that casting into polymorphic schemas is simply not implemented... ๐Ÿ˜ข

Is that the case or / and I doing something wrong?
If it hasn't been implemented yet, is there anything I can do to help?
Any pointers towards what would need to be done?
Do you have any idea how much effort you think it would take this implemented?
As a first step, only supporting polymorphic schemas that use discriminators might make this easier to implement. What do you think?

The full code for my test from which the above snippets are taken from can be found here: maxmellen/open_api_spex_one_of_demo

Running mix test will showcase the issue I am talking about here:

  1) test {"pet_type": "Cat", "meow": "meow"} is cast into %OpenApiSpexOneOfDemo.Schemas.Cat{} (OpenApiSpexOneOfDemo.PetPlugTest)
     test/open_api_spex_one_of_demo/pet_plug_test.exs:8
     Assertion with == failed
     code:  assert conn.resp_body() == "%OpenApiSpexOneOfDemo.Schemas.Cat{meow: \"meow\", pet_type: \"Cat\"}"
     left:  "%OpenApiSpexOneOfDemo.PetPlug.PetRequest{}"
     right: "%OpenApiSpexOneOfDemo.Schemas.Cat{meow: \"meow\", pet_type: \"Cat\"}"
     stacktrace:
       test/open_api_spex_one_of_demo/pet_plug_test.exs:19: (test)

I also have a version of the schemas using a Pet parent schema that can be found on the all-of branch:

defmodule OpenApiSpexOneOfDemo.Schemas do
  defmodule Pet do
    require OpenApiSpex

    alias OpenApiSpex.{Schema, Discriminator}

    OpenApiSpex.schema(%{
      title: "Pet",
      type: :object,
      properties: %{
        pet_type: %Schema{type: :string}
      },
      required: [:pet_type],
      discriminator: %Discriminator{
        propertyName: "pet_type"
      }
    })
  end

  defmodule Cat do
    require OpenApiSpex

    alias OpenApiSpex.Schema

    OpenApiSpex.schema(%{
      title: "Cat",
      type: :object,
      allOf: [
        Pet,
        %Schema{
          type: :object,
          properties: %{
            pet_type: %Schema{type: :string},
            meow: %Schema{type: :string}
          },
          required: [:meow]
        }
      ]
    })
  end

  defmodule Dog do
    require OpenApiSpex

    alias OpenApiSpex.Schema

    OpenApiSpex.schema(%{
      title: "Dog",
      type: :object,
      allOf: [
        Pet,
        %Schema{
          type: :object,
          properties: %{
            pet_type: %Schema{type: :string},
            bark: %Schema{type: :string}
          },
          required: [:bark]
        }
      ]
    })
  end
end

This schema results in the same behavior.


Thanks in advance for your help

Best โœจ

Issue using :oneOf

Hi,

Im currently figuring out, if we should use open_api_spex in our project. There we have the need to use :oneOf (https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/#oneof).

I could not figure out, how to use it. My schema is similar to the following example:

defmodule MySchema do
  alias OpenApiSpex.Schema

  @behaviour OpenApiSpex.Schema
  @derive [Poison.Encoder]
  @schema %Schema{
    title: "MySchema",
    type: :object,
    oneOf: [
      MyOtherSchemaA,
      MyOtherSchemaB
    ],
    properties: %{},
    "x-struct": __MODULE__
  }
  def schema, do: @schema
  defstruct Map.keys(@schema.properties)
end

Running this, give me the following stacks track:

2018-05-25 14:17:53.319  [error] #PID<0.1238.0> running Web.Endpoint terminated
Server: localhost:4000 (http)
Request: GET /api/v1/doc
** (exit) an exception was raised:
    ** (FunctionClauseError) no function clause matching in OpenApiSpex.SchemaResolver.resolve_schema_modules_from_schema/2
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:133: OpenApiSpex.SchemaResolver.resolve_schema_modules_from_schema([MyOtherSchemaA, MyOtherSchemaB], %{})
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:149: OpenApiSpex.SchemaResolver.resolve_schema_modules_from_schema/2
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:172: anonymous fn/2 in OpenApiSpex.SchemaResolver.resolve_schema_modules_from_schema_properties/2
        (stdlib) lists.erl:1263: :lists.foldl/3
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:154: OpenApiSpex.SchemaResolver.resolve_schema_modules_from_schema/2
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:142: OpenApiSpex.SchemaResolver.resolve_schema_modules_from_schema/2
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:98: OpenApiSpex.SchemaResolver.resolve_schema_modules_from_media_type/2
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:92: anonymous fn/2 in OpenApiSpex.SchemaResolver.resolve_schema_modules_from_content/2
        (stdlib) lists.erl:1263: :lists.foldl/3
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:121: OpenApiSpex.SchemaResolver.resolve_schema_modules_from_response/2
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:115: anonymous fn/2 in OpenApiSpex.SchemaResolver.resolve_schema_modules_from_responses/2
        (stdlib) lists.erl:1263: :lists.foldl/3
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:61: OpenApiSpex.SchemaResolver.resolve_schema_modules_from_operation/2
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:53: anonymous fn/2 in OpenApiSpex.SchemaResolver.resolve_schema_modules_from_path_item/2
        (elixir) lib/enum.ex:1899: Enum."-reduce/3-lists^foldl/2-0-"/3
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:43: anonymous fn/2 in OpenApiSpex.SchemaResolver.resolve_schema_modules_from_paths/2
        (stdlib) lists.erl:1263: :lists.foldl/3
        (open_api_spex) lib/open_api_spex/schema_resolver.ex:36: OpenApiSpex.SchemaResolver.resolve_schema_modules/1
        (open_api_spex) lib/open_api_spex/plug/put_api_spec.ex:36: OpenApiSpex.Plug.PutApiSpec.build_spec/1
        (open_api_spex) lib/open_api_spex/plug/put_api_spec.ex:20: OpenApiSpex.Plug.PutApiSpec.call/2

How do I define a schema which should consists of one of two other schemas?

Any help is appreciated.

Cheers,
Tobias

Dialyzer warnings on controller actions caused by CastAndValidate use. Casted request body not conforming to `Plug.Conn.t()` type

When adding casted value to the connection body params this raises dialyzer errors as this does not meet the specification of Plug.Conn.t() where only atom and binary are allowed specificatallly.

  %Plug.Conn{
    :adapter => {atom(), _},
    :assigns => %{atom() => _},
    :before_send => [(map() -> map())],
    :body_params => %Plug.Conn.Unfetched{
      :aspect => atom(),
      binary() =>
        binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
    },
...

Recommendation

In order to meet the specification of Plug.Conn.t() the casted values will need to be put into the body params like so:

conn = %{body_params: %{"request" => %SomeRequest{}}}

Or I will need to make the request schema an unstructured map to enable this without a code change which feels bad as it adds a layer of nesting. I will try this out.

This might be a non-issue, but at the very least this will need documented as request_body function pushes users in this direction during schema definition.

          request_body(
            "Integration registration attributes",
            "application/json",
            __MODULE__.RegistrationRequest
          ),

It is unfortunate that this will require: @dialyzer {:nowarn_function, {:register, 2}} or a less strict exclusion such as :no_return (see examples) as one of the benefits of the casting is the ability to perform this type checking.

Examples:

Controller Action

  def register(conn = %{body_params: request}, _params) do
    %RegistrationRequest{} = request
    render(conn, "not_implemented.json")
  end

The inference of the body_params type as a Struct (as above) is enough for dialyzer to warn:

apps/bond_web/lib/bond_web/controllers/integration_controller.ex:9:no_return
Function register/2 has no local return.

This report becomes more specific when you put the destructure into the function param like so:

  def register(conn = %{body_params: %RegistrationRequest{} = request}, _params) do
    render(conn, "not_implemented.json")
  end
Phoenix.Controller.render(
  _conn :: %{
    :body_params => %BondWeb.Schemas.Integrations.Operations.RegistrationRequest{_ => _},
    _ => _
  },
  <<_::160>>
)

will never return since the success typing is:
(
  %Plug.Conn{
    :adapter => {atom(), _},
    :assigns => %{atom() => _},
    :before_send => [(map() -> map())],
    :body_params => %Plug.Conn.Unfetched{
      :aspect => atom(),
      binary() =>
        binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
    },
    :cookies => %Plug.Conn.Unfetched{:aspect => atom(), binary() => binary()},
    :halted => _,
    :host => binary(),
    :method => binary(),
    :owner => pid(),
    :params => %Plug.Conn.Unfetched{
      :aspect => atom(),
      binary() =>
        binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
    },
    :path_info => [binary()],
    :path_params => %{
      binary() =>
        binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
    },
    :port => char(),
    :private => %{atom() => _},
    :query_params => %Plug.Conn.Unfetched{
      :aspect => atom(),
      binary() =>
        binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
    },
    :query_string => binary(),
    :remote_ip =>
      {byte(), byte(), byte(), byte()}
      | {char(), char(), char(), char(), char(), char(), char(), char()},
    :req_cookies => %Plug.Conn.Unfetched{:aspect => atom(), binary() => binary()},
    :req_headers => [{binary(), binary()}],
    :request_path => binary(),
    :resp_body =>
      nil
      | binary()
      | maybe_improper_list(
          binary() | maybe_improper_list(any(), binary() | []) | byte(),
          binary() | []
        ),
    :resp_cookies => %{binary() => %{}},
    :resp_headers => [{binary(), binary()}],
    :scheme => :http | :https,
    :script_name => [binary()],
    :secret_key_base => nil | binary(),
    :state => :chunked | :file | :sent | :set | :set_chunked | :set_file | :unset,
    :status => nil | non_neg_integer()
  },
  atom() | binary() | Keyword.t() | map()
) :: %Plug.Conn{
  :adapter => {atom(), _},
  :assigns => %{atom() => _},
  :before_send => [(map() -> map())],
  :body_params => %Plug.Conn.Unfetched{
    :aspect => atom(),
    binary() =>
      binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
  },
  :cookies => %Plug.Conn.Unfetched{:aspect => atom(), binary() => binary()},
  :halted => _,
  :host => binary(),
  :method => binary(),
  :owner => pid(),
  :params => %Plug.Conn.Unfetched{
    :aspect => atom(),
    binary() =>
      binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
  },
  :path_info => [binary()],
  :path_params => %{
    binary() =>
      binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
  },
  :port => char(),
  :private => %{atom() => _},
  :query_params => %Plug.Conn.Unfetched{
    :aspect => atom(),
    binary() =>
      binary() | [binary() | [any()] | map()] | %{binary() => binary() | [any()] | map()}
  },
  :query_string => binary(),
  :remote_ip =>
    {byte(), byte(), byte(), byte()}
    | {char(), char(), char(), char(), char(), char(), char(), char()},
  :req_cookies => %Plug.Conn.Unfetched{:aspect => atom(), binary() => binary()},
  :req_headers => [{binary(), binary()}],
  :request_path => binary(),
  :resp_body =>
    nil
    | binary()
    | maybe_improper_list(
        binary() | maybe_improper_list(any(), binary() | []) | byte(),
        binary() | []
      ),
  :resp_cookies => %{binary() => %{}},
  :resp_headers => [{binary(), binary()}],
  :scheme => :http | :https,
  :script_name => [binary()],
  :secret_key_base => nil | binary(),
  :state => :sent,
  :status => nil | non_neg_integer()
}

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

Some issues when casting and validating oneOf polymorphic schemas

We are using open_api_spex to document our apis and slowly adding schema validation in our tests. However we are seeing some unexpected result and errors mostly to using the oneOf schemas.

In order to describe what we are seeing (maybe its because we are not correctly setting up the schema) we created a small scenario using the example on the swagger.io site: https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/#oneof

On the swagger site 3 examples are given with explanation why data should be valid or invalid. We implemented these as test, unfortunately we cannot seem to make the tests pass.

defmodule OAS do
  require OpenApiSpex
  alias OpenApiSpex.{Schema, OpenApi, Components}

  defmodule Cat,
    do:
      OpenApiSpex.schema(%{
        title: "Cat",
        type: :object,
        properties: %{
          hunts: %Schema{type: :boolean},
          age: %Schema{type: :integer}
        }
      })

  defmodule Dog,
    do:
      OpenApiSpex.schema(%{
        title: "Dog",
        type: :object,
        properties: %{
          bark: %Schema{type: :boolean},
          breed: %Schema{type: :string, enum: ["Dingo", "Husky", "Retriever", "Shepherd"]}
        }
      })

  defmodule CatOrDog,
    do:
      OpenApiSpex.schema(%{
        title: "CatOrDog",
        anyOf: [OAS.Cat, OAS.Dog]
      })

  def spec() do
    schemas =
      for module <- [OAS.Cat, OAS.Dog, OAS.CatOrDog], into: %{} do
        {module.schema().title, module.schema()}
      end

    %OpenApi{info: %{}, paths: %{}, components: %Components{schemas: schemas}}
    |> OpenApiSpex.resolve_schema_modules()
  end
end

defmodule OASTest do
  @moduledoc false

  use ExUnit.Case
  alias OpenApiSpex.Schema

  @api_spec OAS.spec()
  @schema @api_spec.components.schemas["CatOrDog"]

  test "casting yields strange data" do
    input = %{"bark" => true, "breed" => "Dingo"}

    assert {:ok, %OAS.Dog{bark: true, breed: "Dingo"}} =
             OpenApiSpex.cast(@api_spec, @schema, input)
  end

  test "should be invalid (not valid against both schemas)" do
    input = %{"bark" => true, "hunts" => true}
    refute :ok == OpenApiSpex.validate(@api_spec, @schema, input)
  end

  test "should be invalid (valid against both)" do
    input = %{"bark" => true, "hunts" => true, "breed" => "Husky", "age" => 3}
    refute :ok == OpenApiSpex.validate(@api_spec, @schema, input)
  end
end

Are we missing something ? Any suggestion would be awesome !

Dialyzer warnings when defining security schemes in spec

Hello @mbuhot ,

i already found another small problem. Basically, we are defining the spec just as you describe in the README.

defmodule UnifysellApiWeb.ApiSpec do
  alias OpenApiSpex.{OpenApi, Server, Info, Paths}

  @spec spec :: any
  def spec do
    %OpenApi{
      servers: [
        # Populate the Server info from a phoenix endpoint
        Server.from_endpoint(UnifysellApiWeb.Endpoint, otp_app: :unifysell_api)
      ],
      info: %Info{
        title: "UnifysellApi",
        version: "0.0.1"
      },
      # populate the paths from a phoenix router
      paths: Paths.from_router(UnifysellApiWeb.Router),
      components: %OpenApiSpex.Components{
        securitySchemes: %{
          OAuthBearer: %OpenApiSpex.SecurityScheme{
            type: "http",
            scheme: "bearer",
            bearerFormat: "JWT"
          }
        }
      }
    }
    # discover request/response schemas from path specs
    |> OpenApiSpex.resolve_schema_modules()
  end
end

We added a definition for an OAuth security scheme. The problem now is, that dialyzer is reporting the following warning: (i inserted the linebreak for better readability)

lib/unifysell_api_web/api_spec.ex:40: The call 'Elixir.OpenApiSpex':resolve_schema_modules
(#{'__struct__':='Elixir.OpenApiSpex.OpenApi', 'components':=#{'__struct__':='Elixir.OpenApiSpex.Components', 'callbacks':='nil', 'examples':='nil', 'headers':='nil', 'links':='nil', 'parameters':='nil', 'requestBodies':='nil', 'responses':='nil', 'schemas':='nil', 'securitySchemes':=#{'OAuthBearer2xx':=#{'__struct__':='Elixir.OpenApiSpex.SecurityScheme', 'bearerFormat':='nil', 'description':='nil', 'flows':='nil', 'in':=<<_:48>>, 'name':=<<_:104>>, 'openIdConnectUrl':='nil', 'scheme':='nil', 'type':=<<_:48>>}, 'OAuthBearer300':=#{'__struct__':='Elixir.OpenApiSpex.SecurityScheme', 'bearerFormat':=<<_:24>>, 'description':='nil', 'flows':='nil', 'in':='nil', 'name':='nil', 'openIdConnectUrl':='nil', 'scheme':=<<_:48>>, 'type':=<<_:32>>}}}, 'externalDocs':='nil', 'info':=#{'__struct__':='Elixir.OpenApiSpex.Info', 'contact':='nil', 'description':='nil', 'license':='nil', 'termsOfService':='nil', 'title':=<<_:96>>, 'version':=<<_:40>>}, 'openapi':=<<_:40>>, 'paths':=#{binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=#{'__struct__':='Elixir.OpenApiSpex.Operation', 'callbacks':='nil' | #{binary()=>#{'$ref'=>binary(), '__struct__'=>'Elixir.OpenApiSpex.Reference', binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), _=>_}}}, 'deprecated':='false' | 'nil' | 'true', 'description':='nil' | binary(), 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'operationId':='nil' | binary(), 'parameters':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>map(), 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>map(), 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | map(), 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'requestBody':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.RequestBody', '$ref'=>binary(), 'content'=>#{binary()=>map()}, 'description'=>binary(), 'required'=>boolean()}, 'responses':=#{'default':=#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}, integer()=>#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}}, 'security':='nil' | [#{binary()=>[any()]}], 'servers':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'summary':='nil' | binary(), 'tags':='nil' | [binary()]}, 'description':=binary(), 'get':=#{'__struct__':='Elixir.OpenApiSpex.Operation', 'callbacks':='nil' | #{binary()=>#{'$ref'=>binary(), '__struct__'=>'Elixir.OpenApiSpex.Reference', binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), _=>_}}}, 'deprecated':='false' | 'nil' | 'true', 'description':='nil' | binary(), 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'operationId':='nil' | binary(), 'parameters':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>map(), 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>map(), 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | map(), 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'requestBody':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.RequestBody', '$ref'=>binary(), 'content'=>#{binary()=>map()}, 'description'=>binary(), 'required'=>boolean()}, 'responses':=#{'default':=#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}, integer()=>#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}}, 'security':='nil' | [#{binary()=>[any()]}], 'servers':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'summary':='nil' | binary(), 'tags':='nil' | [binary()]}, 'head':=#{'__struct__':='Elixir.OpenApiSpex.Operation', 'callbacks':='nil' | #{binary()=>#{'$ref'=>binary(), '__struct__'=>'Elixir.OpenApiSpex.Reference', binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), _=>_}}}, 'deprecated':='false' | 'nil' | 'true', 'description':='nil' | binary(), 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'operationId':='nil' | binary(), 'parameters':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>map(), 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>map(), 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | map(), 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'requestBody':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.RequestBody', '$ref'=>binary(), 'content'=>#{binary()=>map()}, 'description'=>binary(), 'required'=>boolean()}, 'responses':=#{'default':=#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}, integer()=>#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}}, 'security':='nil' | [#{binary()=>[any()]}], 'servers':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'summary':='nil' | binary(), 'tags':='nil' | [binary()]}, 'options':=#{'__struct__':='Elixir.OpenApiSpex.Operation', 'callbacks':='nil' | #{binary()=>#{'$ref'=>binary(), '__struct__'=>'Elixir.OpenApiSpex.Reference', binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), _=>_}}}, 'deprecated':='false' | 'nil' | 'true', 'description':='nil' | binary(), 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'operationId':='nil' | binary(), 'parameters':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>map(), 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>map(), 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | map(), 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'requestBody':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.RequestBody', '$ref'=>binary(), 'content'=>#{binary()=>map()}, 'description'=>binary(), 'required'=>boolean()}, 'responses':=#{'default':=#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}, integer()=>#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}}, 'security':='nil' | [#{binary()=>[any()]}], 'servers':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'summary':='nil' | binary(), 'tags':='nil' | [binary()]}, 'parameters':=[#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>#{binary()=>map()}, 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>#{binary()=>map()}, 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Schema', '$ref'=>binary(), 'additionalProperties'=>atom() | map(), 'allOf'=>'nil' | [any()], 'anyOf'=>'nil' | [any()], 'default'=>_, 'deprecated'=>'false' | 'nil' | 'true', 'description'=>binary(), 'discriminator'=>'nil' | map(), 'enum'=>'nil' | [any()], 'example'=>_, 'exclusiveMaximum'=>'false' | 'nil' | 'true', 'exclusiveMinimum'=>'false' | 'nil' | 'true', 'externalDocs'=>'nil' | map(), 'format'=>'nil' | binary(), 'items'=>atom() | map(), 'maxItems'=>'nil' | integer(), 'maxLength'=>'nil' | integer(), 'maxProperties'=>'nil' | integer(), 'maximum'=>'nil' | number(), 'minItems'=>'nil' | integer(), 'minLength'=>'nil' | integer(), 'minProperties'=>'nil' | integer(), 'minimum'=>'nil' | number(), 'multipleOf'=>'nil' | number(), 'not'=>atom() | map(), 'nullable'=>'false' | 'nil' | 'true', 'oneOf'=>'nil' | [any()], 'pattern'=>'nil' | binary() | map(), 'properties'=>'nil' | map(), 'readOnly'=>'false' | 'nil' | 'true', 'required'=>'nil' | [any()], 'title'=>binary(), 'type'=>atom(), 'uniqueItems'=>'false' | 'nil' | 'true', 'writeOnly'=>'false' | 'nil' | 'true', 'x-struct'=>atom(), 'xml'=>'nil' | map()}, 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'patch':=#{'__struct__':='Elixir.OpenApiSpex.Operation', 'callbacks':='nil' | #{binary()=>#{'$ref'=>binary(), '__struct__'=>'Elixir.OpenApiSpex.Reference', binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), _=>_}}}, 'deprecated':='false' | 'nil' | 'true', 'description':='nil' | binary(), 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'operationId':='nil' | binary(), 'parameters':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>map(), 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>map(), 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | map(), 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'requestBody':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.RequestBody', '$ref'=>binary(), 'content'=>#{binary()=>map()}, 'description'=>binary(), 'required'=>boolean()}, 'responses':=#{'default':=#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}, integer()=>#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}}, 'security':='nil' | [#{binary()=>[any()]}], 'servers':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'summary':='nil' | binary(), 'tags':='nil' | [binary()]}, 'post':=#{'__struct__':='Elixir.OpenApiSpex.Operation', 'callbacks':='nil' | #{binary()=>#{'$ref'=>binary(), '__struct__'=>'Elixir.OpenApiSpex.Reference', binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), _=>_}}}, 'deprecated':='false' | 'nil' | 'true', 'description':='nil' | binary(), 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'operationId':='nil' | binary(), 'parameters':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>map(), 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>map(), 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | map(), 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'requestBody':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.RequestBody', '$ref'=>binary(), 'content'=>#{binary()=>map()}, 'description'=>binary(), 'required'=>boolean()}, 'responses':=#{'default':=#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}, integer()=>#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}}, 'security':='nil' | [#{binary()=>[any()]}], 'servers':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'summary':='nil' | binary(), 'tags':='nil' | [binary()]}, 'put':=#{'__struct__':='Elixir.OpenApiSpex.Operation', 'callbacks':='nil' | #{binary()=>#{'$ref'=>binary(), '__struct__'=>'Elixir.OpenApiSpex.Reference', binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), _=>_}}}, 'deprecated':='false' | 'nil' | 'true', 'description':='nil' | binary(), 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'operationId':='nil' | binary(), 'parameters':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>map(), 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>map(), 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | map(), 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'requestBody':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.RequestBody', '$ref'=>binary(), 'content'=>#{binary()=>map()}, 'description'=>binary(), 'required'=>boolean()}, 'responses':=#{'default':=#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}, integer()=>#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}}, 'security':='nil' | [#{binary()=>[any()]}], 'servers':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'summary':='nil' | binary(), 'tags':='nil' | [binary()]}, 'servers':=[#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=#{binary()=>map()}}], 'summary':=binary(), 'trace':=#{'__struct__':='Elixir.OpenApiSpex.Operation', 'callbacks':='nil' | #{binary()=>#{'$ref'=>binary(), '__struct__'=>'Elixir.OpenApiSpex.Reference', binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), _=>_}}}, 'deprecated':='false' | 'nil' | 'true', 'description':='nil' | binary(), 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'operationId':='nil' | binary(), 'parameters':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Parameter' | 'Elixir.OpenApiSpex.Reference', '$ref'=>binary(), 'allowEmptyValue'=>boolean(), 'allowReserved'=>boolean(), 'content'=>map(), 'deprecated'=>boolean(), 'description'=>binary(), 'example'=>_, 'examples'=>map(), 'explode'=>boolean(), 'in'=>'cookie' | 'header' | 'path' | 'query', 'name'=>atom(), 'required'=>boolean(), 'schema'=>atom() | map(), 'style'=>'deep' | 'form' | 'label' | 'matrix' | 'pipeDelimited' | 'simple' | 'spaceDelimited'}], 'requestBody':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.RequestBody', '$ref'=>binary(), 'content'=>#{binary()=>map()}, 'description'=>binary(), 'required'=>boolean()}, 'responses':=#{'default':=#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}, integer()=>#{'__struct__':='Elixir.OpenApiSpex.Reference' | 'Elixir.OpenApiSpex.Response', '$ref'=>binary(), 'content'=>map(), 'description'=>binary(), 'headers'=>'nil' | map(), 'links'=>'nil' | map()}}, 'security':='nil' | [#{binary()=>[any()]}], 'servers':='nil' | [#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'summary':='nil' | binary(), 'tags':='nil' | [binary()]}}}, 'security':=[], 'servers':=[#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil', 'url':=<<_:32,_:_*8>>, 'variables':=#{}},...], 'tags':=[]})
will never return since it differs in the 1st argument from the success typing arguments:
(#{'__struct__':='Elixir.OpenApiSpex.OpenApi', 'components':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Components', 'callbacks':=#{binary()=>map()}, 'examples':=#{binary()=>map()}, 'headers':=#{binary()=>map()}, 'links':=#{binary()=>map()}, 'parameters':=#{binary()=>map()}, 'requestBodies':=#{binary()=>map()}, 'responses':=#{binary()=>map()}, 'schemas':=#{binary()=>map()}, 'securitySchemes':=#{binary()=>map()}}, 'externalDocs':='nil' | #{'__struct__':='Elixir.OpenApiSpex.ExternalDocumentation', 'description':=binary(), 'url':=binary()}, 'info':=#{'__struct__':='Elixir.OpenApiSpex.Info', 'contact':='nil' | #{'__struct__':='Elixir.OpenApiSpex.Contact', 'email':=binary(), 'name':=binary(), 'url':=binary()}, 'description':='nil' | binary(), 'license':='nil' | #{'__struct__':='Elixir.OpenApiSpex.License', 'name':=binary(), 'url':=binary()}, 'termsOfService':='nil' | binary(), 'title':=binary(), 'version':=binary()}, 'openapi':=binary(), 'paths':=#{binary()=>#{'$ref':=binary(), '__struct__':='Elixir.OpenApiSpex.PathItem', 'delete':=map(), 'description':=binary(), 'get':=map(), 'head':=map(), 'options':=map(), 'parameters':=[any()], 'patch':=map(), 'post':=map(), 'put':=map(), 'servers':=[any()], 'summary':=binary(), 'trace':=map()}}, 'security':=[#{binary()=>[any()]}], 'servers':=[#{'__struct__':='Elixir.OpenApiSpex.Server', 'description':='nil' | binary(), 'url':=binary(), 'variables':=map()}], 'tags':=[#{'__struct__':='Elixir.OpenApiSpex.Tag', 'description':=binary(), 'externalDocs':=map(), 'name':=binary()}]})

The problem is, that we are inserting an incomplete %OpenApiSpex.Components{} struct, that is merged with the schemas definition later. Do you have any ideas how we could handle that?

schema: load from file and validate request and response

Hi,

Is this possible, given I have a request and a response that I wish to validate. I'd like to:

  1. Load schema from file.
  2. Call a validate function in the API for the request.
  3. Call a validate function in the API for the response.

I don't want to use Plugs, I'd like to use it as a pure API.

Validate schemas using ex_json_schema

Following on #23 (and my attempt at solving it: #42), I have been instead using the really good ex_json_schema.

Here is the code I am using:

    case %{
           "components" => components,
           "additionalProperties" => false,
           "type" => "object",
           "required" => [schema_name],
           "properties" => %{
             schema_name => %{"$ref" => "#/components/schemas/#{schema_name}"}
           }
         }
         |> ExJsonSchema.Schema.resolve()
         |> ExJsonSchema.Validator.validate(resp)

There needs to be a translation step first though, to rewrite nullable: true and such incompatibilities of OpenAPIV3 schemas with JSON Schema Draft-04.

Would you be open to use ex_json_schema validators instead of the current ones in this lib?
Where do you think this translation step should be: within this lib or within ex_json_schema?

Thanks

Can not cast GET request without content-type header

After merging #45 it is not possible anymore to cast GET request, because of GET request does not contains request body, no content type header is required, which leads to this exception:

  ** (FunctionClauseError) no function clause matching in String.split/3

     The following arguments were given to String.split/3:
     
         # 1
         nil
     
         # 2
         ";"
     
         # 3
         []
     
     Attempted function clauses (showing 4 out of 4):
     
         def split(string, %Regex{} = pattern, options) when is_binary(string)
         def split(string, "", options) when is_binary(string)
         def split(string, pattern, []) when is_tuple(pattern) or is_binary(string)
         def split(string, pattern, options) when is_binary(string)
     
     code: conn = .....
     stacktrace:
       (elixir) lib/string.ex:383: String.split/3
       (open_api_spex) lib/open_api_spex/plug/cast.ex:58: OpenApiSpex.Plug.Cast.call/2

Support assert_schema for array type

If I have a schema like the following

    OpenApiSpex.schema(%{
      title: "Attributes",
      type: :array,
      items: Api.Schemas.Attribute
    })
  end

would be nice to do assert_schema("Attributes", spec)

This at the moment does not work as assert_schema accepts a map.
Is it something something that can be added?

Multiple content-type for req/resp header

Hi,

Trying to document an endpoint that accept and response to different content-type.
Example:

multi_content_type
multi_content_type_2

Either, response and request_body typedoc describes content: %{String.t => MediaType.t} | nil,

Any advice?
Many thanks in advance,

Not able to generate spec.json by following the docs

I was trying to generate the json docs for a module I am working on and wasn't able to. After some time trying I decided to try with a clean phoenix server project. Copying and pasting the code from the docs and running the command:

mix apitest.open_api_spec spec.json

I couldn't get the command to work, getting the same error I have in my own personal project. Which is this:

** (ArgumentError) argument error
    (stdlib) :ets.lookup(ApitestWeb.Endpoint, :__phoenix_struct_url__)
    (phoenix) lib/phoenix/config.ex:42: Phoenix.Config.cache/3
    lib/open_api_spex/server.ex:39: OpenApiSpex.Server.from_endpoint/1
    lib/api_spec.ex:11: ApitestWeb.ApiSpec.spec/0
    lib/apitest/openapispec.ex:4: Mix.Tasks.Apitest.OpenApiSpec.run/1
    (mix) lib/mix/task.ex:331: Mix.Task.run_task/3
    (mix) lib/mix/cli.ex:79: Mix.CLI.run_task/2
    (elixir) lib/code.ex:767: Code.require_file/2

Not sure exactly what is going on but I am following the getting started docs to the letter. I can share the code if it helps.

And I am using phoenix v1.4.6

I didn't want to put a lot of information as to not bloat the issue, but I'll be more than happy to provide any information you deem relevant.

push a new version to hexdocs

Hello mbuhot,

thanks for the latest updates. They look very promising. Could you please release a new version on hexdocs to make them available to us? Or is there anything left to do before you can do so?

Greetings Thomas

OpenApiSpex.Cast.String.cast/1 not casting dates or date-times

cast/1 should cast string types with a format of :date to a %Date{}, and a format of :"date-time" to %DateTime{}. Instead, it passes the input string untouched.

The old cast function casted date and date-time strings to their respective Elixir types, but the new module doesn't.

Here are the invalid tests:

Fix spec issue introduced in PR #69

A concern about divergence of the Open API spec was brought up here: #69 (comment)

Adjust the behavior in the string validation logic. The current behavior is to trim the string before validating string length. Change it to not trim the string before validating string length. The reason for trimming was to provide a cleaner way to validate non-blankness. However, this is not supported by the spec. The workaround for validating non-blankness is to use a regular expression validation with the pattern /^\S+$/.

Optionally exclude some fields when serializing

  • Values that are empty such as tags security
  • Values from schemas such as x-struct, title

I believe that since serialization is not part of the lib this has to be done somewhere else, but maybe there's a Protocol to implement that can use the "omitempty" idiom of Golang? I may be wrong as I am somewhat new to Elixir.

Thanks

Allow defining operations by module attributes

Example implementation that I have used in my project (still lacks a little bit options, but overall idea can be seen):

defmodule KokonWeb.Rest do
  alias OpenApiSpex.Operation

  defmacro __using__(_opts) do
    quote do
      alias KokonWeb.Rest.Schema
      alias OpenApiSpex.Operation

      plug(OpenApiSpex.Plug.Cast)
      plug(OpenApiSpex.Plug.Validate)

      @on_definition KokonWeb.Rest
      @before_compile KokonWeb.Rest

      Module.register_attribute(__MODULE__, :parameter, accumulate: true)
      Module.register_attribute(__MODULE__, :response, accumulate: true)

      Module.register_attribute(__MODULE__, :open_api_operations, accumulate: true)
    end
  end

  def __on_definition__(_env, _type, :open_api_operation, _args, _guards, _body), do: nil
  def __on_definition__(_env, _type, :action, _args, _guards, _body), do: nil

  def __on_definition__(%Macro.Env{module: mod}, :def, name, _args, _guards, _body) do
    parameters = Module.delete_attribute(mod, :parameter)
    response = Module.delete_attribute(mod, :response)
    {summary, doc} = docs(Module.get_attribute(mod, :doc))

    operation =
      %Operation{
        summary: summary || "TODO",
        description: doc,
        operationId: module_name(mod) <> ".#{name}",
        parameters: parameters,
        responses: Map.new(response)
      }

    Module.put_attribute(mod, :open_api_operations, {name, operation})
  end

  def __on_definition__(_env, _type, _name, _args, _guards, _body), do: nil

  defmacro __before_compile__(_env) do
    quote unquote: false do
      for {name, operation} <- Module.delete_attribute(__MODULE__, :open_api_operations) do
        def open_api_operation(unquote(name)), do: unquote(Macro.escape(operation))
      end
    end
  end

  defp docs(nil), do: {nil, nil}

  defp docs({_, doc}) do
    [summary | _] = String.split(doc, ~r/\n\s*\n/, parts: 2)

    {summary, doc}
  end

  defp module_name(mod) when is_atom(mod), do: module_name(Atom.to_string(mod))
  defp module_name("Elixir." <> name), do: name
  defp module_name(name) when is_binary(name), do: name
end

This causes controller to look like this:

defmodule KokonWeb.Rest.Controllers.Schedule do
  use KokonWeb.Rest

  @doc "List schedule"
  @response {200, Operation.response(
    "Submissions",
    "application/json",
    KokonWeb.Rest.Schema.Submissions
  )}
  def index(conn, _params) do
    {:ok, submissions} = Kokon.Submissions.all()

    json(conn, submissions)
  end

  @doc "Create new submission"
  @parameter Operation.parameter(:title, :query, :string, "Submission title",
    required: true
  )
  @parameter Operation.parameter(
    :abstract,
    :query,
    :string,
    "Submission description",
    required: true
  )
  @response {200, Operation.response(
    "Submissions",
    "application/json",
    KokonWeb.Rest.Schema.Submission
  )}
  def create(conn, %{title: title, abstract: abstract}) do
    with {:ok, submission} <-
      Kokon.Submissions.create(%{title: title, abstract: abstract}) do
      json(conn, submission)
    end
  end
end

I am opening this as an issue instead of PR as I would like to know opinions about this beforehand.

Duplicated operationId for multiple routes with same controller function

I'm using a lot of Phoenix Resource Controllers in my project.

Since the 'update' function is registered on the patch and put routes for the resource controller, there will also be a patch and put call in the openapi spec.

The problem now is, that the definition for the update function is identicaly on both calls, including the operation id.

This isn't actually just e resource controller problem. Duplicated operation Ids will be present as soon as multiple routes lead to the same controller function.

Allow disabling cache in OpenApiSpex.Plug.PutApiSpec

Caching can be problematic in development and cleaning configuration after each recompilation can be troublesome.

Additionally such behaviour can be problematic when doing hot upgrades.

One possible solution is to store mod.module_info(:md5) along the cached data and drop everything on hash change.

:type property of Schema struct should not be required

I need to represent a parameter that can be an integer or a string in this way:

%OpenApiSpex.Schema{
  oneOf: [
    %OpenApiSpex.Schema{type: :integer},
    %OpenApiSpex.Schema{type: :string}
  ]
}

but this results in error

** (ArgumentError) the following keys must also be given when building struct OpenApiSpex.Schema: [:type]

The same happens using references and it the documentation example

%OpenApiSpex.Schema{
  oneOf: [
    %OpenApiSpex.Reference{"$ref": "#/components/schemas/Foo"},
    %OpenApiSpex.Reference{"$ref": "#/components/schemas/Bar"}
  ]
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.