Page MenuHomePhorge

No OneTemporary

Size
41 KB
Referenced Files
None
Subscribers
None
diff --git a/README.md b/README.md
index 5921c79..9a65a47 100644
--- a/README.md
+++ b/README.md
@@ -1,414 +1,459 @@
# Open API Spex
[![Build Status](https://travis-ci.com/open-api-spex/open_api_spex.svg?branch=master)](https://travis-ci.com/open-api-spex/open_api_spex)
[![Hex.pm](https://img.shields.io/hexpm/v/open_api_spex.svg)](https://hex.pm/packages/open_api_spex)
Leverage Open Api Specification 3 (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 with [SwaggerUI](https://swagger.io/swagger-ui/)
Full documentation available on [hexdocs](https://hexdocs.pm/open_api_spex/)
## Installation
The package can be installed by adding `open_api_spex` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:open_api_spex, "~> 3.4"}
]
end
```
## Generate Spec
Start by adding an `ApiSpec` module to your application to populate an `OpenApiSpex.OpenApi` struct.
```elixir
defmodule MyAppWeb.ApiSpec do
alias OpenApiSpex.{OpenApi, Server, Info, Paths}
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
```
For each plug (controller) that will handle api requests, add an `open_api_operation` callback.
It will be passed the plug opts that were declared in the router, this will be the action for a phoenix controller. The callback populates an `OpenApiSpex.Operation` struct describing the plug/action.
```elixir
defmodule MyAppWeb.UserController do
alias OpenApiSpex.Operation
alias MyAppWeb.Schemas.UserResponse
@spec open_api_operation(atom) :: Operation.t()
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
@spec show_operation() :: Operation.t()
def show_operation() do
%Operation{
tags: ["users"],
summary: "Show user",
description: "Show a user by ID",
operationId: "UserController.show",
parameters: [
Operation.parameter(:id, :path, :integer, "User ID", example: 123, required: true)
],
responses: %{
200 => Operation.response("User", "application/json", UserResponse)
}
}
end
# Controller's `show` action
def show(conn, %{id: id}) do
{:ok, user} = MyApp.Users.find_by_id(id)
json(conn, 200, user)
end
end
```
Alternatively, you can create an operation file separately using `defdelegate`.
```elixir
# Phoenix's controller
defmodule MyAppWeb.UserController do
defdelegate open_api_operation(action), to: MyAppWeb.UserApiOperation
def show(conn, %{id: id}) do
{:ok, user} = MyApp.Users.find_by_id(id)
json(conn, 200, user)
end
end
# Open API Spex operations
defmodule MyAppWeb.UserApiOperation do
alias OpenApiSpex.Operation
alias MyAppWeb.Schemas.UserResponse
@spec open_api_operation(atom) :: Operation.t()
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
@spec show_operation() :: Operation.t()
def show_operation() do
%Operation{
tags: ["users"],
summary: "Show user",
description: "Show a user by ID",
operationId: "UserController.show",
parameters: [
Operation.parameter(:id, :path, :integer, "User ID", example: 123, required: true)
],
responses: %{
200 => Operation.response("User", "application/json", UserResponse)
}
}
end
end
```
For examples of other action operations, see the
[example web app](https://github.com/open-api-spex/open_api_spex/blob/master/examples/phoenix_app/lib/phoenix_app_web/controllers/user_controller.ex).
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.
```elixir
defmodule MyAppWeb.Schemas do
alias OpenApiSpex.Schema
defmodule User do
require OpenApiSpex
OpenApiSpex.schema(%{
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" => "joe@gmail.com",
"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" => "joe@gmail.com",
"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](https://github.com/open-api-spex/open_api_spex/blob/master/examples/phoenix_app/lib/phoenix_app_web/schemas.ex)
## Serve the Spec
To serve the API spec from your application, first add the `OpenApiSpex.Plug.PutApiSpec` plug somewhere in the pipeline.
```elixir
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:
```elixir
scope "/api" do
pipe_through :api
resources "/users", MyAppWeb.UserController, only: [:create, :index, :show]
get "/openapi", OpenApiSpex.Plug.RenderSpec, []
end
```
## Generating the Spec
Optionally, you can create a mix task to write the swagger file to disk:
```elixir
defmodule Mix.Tasks.MyApp.OpenApiSpec do
def run([output_file]) do
MyAppWeb.Endpoint.start_link() # Required if using for OpenApiSpex.Server.from_endpoint/1
json =
MyAppWeb.ApiSpec.spec()
|> Jason.encode!(pretty: true)
:ok = File.write!(output_file, json)
end
end
```
Generate the file with: `mix my_app.openapispec spec.json`
## 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.
```elixir
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
> :warning: This functionality currently converts Strings into Atoms, which makes it potentially [vulnerable to DoS attacks](https://til.hashrocket.com/posts/gkwwfy9xvw-converting-strings-to-atoms-safely). 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:
```elixir
# 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!()
|> OpenApiSpex.OpenApi.Decode.decode()
```
You can then use the loaded spec to with `OpenApiSpex.cast_and_validate/4`, like:
```elixir
{:ok, _} = OpenApiSpex.cast_and_validate(
open_api_spec_from_json, # or open_api_spec_from_yaml
spec.paths["/some_path"].post,
test_conn,
"application/json"
)
```
## 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. This section
describes the former.
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.
```elixir
# Phoenix
plug OpenApiSpex.Plug.CastAndValidate
# Plug
plug OpenApiSpex.Plug.CastAndValidate, operation_id: "UserController.create
```
For Phoenix apps, the `operation_id` can be inferred from the contents of `conn.private`.
```elixir
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
alias OpenApiSpex.Operation
alias MyAppWeb.Schemas.{User, UserRequest, UserResponse}
plug OpenApiSpex.Plug.CastAndValidate
def open_api_operation(action) do
apply(__MODULE__, :"#{action}_operation", [])
end
def create_operation do
import Operation
%Operation{
tags: ["users"],
summary: "Create user",
description: "Create a user",
operationId: "UserController.create",
parameters: [
parameter(:id, :query, :integer, "user ID")
],
requestBody: request_body("The user attributes", "application/json", UserRequest),
responses: %{
201 => response("User", "application/json", UserResponse)
}
}
end
def create(conn = %{body_params: %UserRequest{user: %User{name: name, email: email, birthday: birthday = %Date{}}}}, %{id: id}) do
# conn.body_params cast to UserRequest struct
# conn.params.id cast to integer
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:
```json
{
"errors": [
{
"message": "Invalid format. Expected :date",
"source": {
"pointer": "/data/birthday"
},
"title": "Invalid value"
}
]
}
```
See also `OpenApiSpex.cast_value/3` for casting and validating outside of a `plug` pipeline.
## 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.
```elixir
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:
```elixir
use MyAppWeb.ConnCase
import OpenApiSpex.TestAssertions
test "UserController produces a UsersResponse", %{conn: conn} do
api_spec = MyAppWeb.ApiSpec.spec()
json =
conn
|> get(user_path(conn, :index))
|> json_response(200)
assert_schema(json, "UsersResponse", api_spec)
end
```
+
+## Experimental ExDoc-Based API Specifications
+
+Starting with version 3.5.0, a new, experimental API is available to specify endpoints from the controller,
+using ExDoc tags. See the example below.
+
+Because this feature is experimental, it is likely to change in the future, until it becomes stable. Use
+at your own risk.
+
+```elixir
+defmodule MyAppWeb.UserController do
+ use MyAppWeb, :controller
+
+ @doc """
+ List users
+ """
+ @doc responses: [
+ ok: {
+ {"Users", "application/json", MyAppWeb.Schema.Users}
+ }
+ ]
+ def index(conn, _params) do
+ {:ok, users} = MyApp.Users.all()
+
+ json(conn, users)
+ end
+
+ @doc """
+ Update user
+ """
+ @doc parameters: [
+ id: [in: :query, type: :string, required: true, description: "User ID"]
+ ]
+ @doc responses: [
+ ok: {
+ {"User", "application/json", MyAppWeb.Schema.User}
+ }
+ ]
+ def update(conn, %{id: id}) do
+ with {:ok, user} <- MyApp.Users.update(conn.body_params) do
+ json(conn, user)
+ end
+ end
+end
+```
diff --git a/lib/open_api_spex/controller.ex b/lib/open_api_spex/controller.ex
new file mode 100644
index 0000000..bb18d32
--- /dev/null
+++ b/lib/open_api_spex/controller.ex
@@ -0,0 +1,158 @@
+defmodule OpenApiSpex.Controller do
+ @moduledoc ~S'''
+ Generation of OpenAPI documentation via ExDoc documentation and tags.
+
+ ## Supported OpenAPI fields
+
+ Attribute `operationId` is automatically provided by the implementation
+ and cannot be changed in any way. It is constructed as `Module.Name.function_name`
+ in the same way as function references in backtraces.
+
+ ### `description` and `summary`
+
+ Description of endpoint will be filled with documentation string in the same
+ manner as ExDocs, so first line will be used as a `summary` and whole
+ documentation will be used as `description` field.
+
+ ### `parameters`
+
+ Parameters of the endpoint are defined by `:parameters` tag which should be
+ map or keyword list that is formed as:
+
+ ```
+ [
+ param_name: definition
+ ]
+ ```
+
+ Where `definition` is `OpenApiSpex.Parameter.t()` structure or map or keyword
+ list that accepts the same arguments.
+
+ ### `responses`
+
+ Responses are controlled by `:responses` tag. Responses must be defined as
+ a map or keyword list in form of:
+
+ ```
+ %{
+ 200 => {"Response name", "application/json", schema},
+ :not_found => {"Response name", "application/json", schema}
+ }
+ ```
+
+ Where atoms are the same as `Plug.Conn.Status.code/1` values.
+
+ ### `requestBody`
+
+ Controlled by `:body` parameter and is defined as a tuple in form
+ `{description, mime, schema}`.
+
+ ### `tags`
+
+ Tags are controlled by `:tags` attribute. In contrast to other attributes, this
+ one will also inherit all tags defined as a module documentation attributes.
+
+ ## Example
+
+ ```
+ defmodule FooController do
+ use #{inspect(__MODULE__)}
+
+ @moduledoc tags: ["Foos"]
+
+ @doc """
+ Endpoint summary
+
+ More docs
+ """
+ @doc [
+ parameters: [
+ id: [in: :path, type: :string, required: true]
+ ],
+ responses: [
+ ok: {"Foo document", "application/json", FooSchema}
+ ]
+ ]
+ def show(conn, %{id: id}) do
+ # …
+ end
+ end
+ ```
+ '''
+
+ alias OpenApiSpex.Operation
+
+ defmacro __using__(_opts) do
+ quote do
+ @doc false
+ @spec open_api_operation(atom()) :: OpenApiSpex.Operation.t()
+ def open_api_operation(name),
+ do: unquote(__MODULE__).__api_operation__(__MODULE__, name)
+
+ defoverridable open_api_operation: 1
+ end
+ end
+
+ @doc false
+ @spec __api_operation__(module(), atom()) :: Operation.t() | nil
+ def __api_operation__(mod, name) do
+ with {:ok, {mod_meta, summary, docs, meta}} <- get_docs(mod, name) do
+ %Operation{
+ description: docs,
+ operationId: "#{inspect(mod)}.#{name}",
+ parameters: build_parameters(meta),
+ requestBody: build_request_body(meta),
+ responses: build_responses(meta),
+ summary: summary,
+ tags: Map.get(mod_meta, :tags, []) ++ Map.get(meta, :tags, [])
+ }
+ else
+ _ -> nil
+ end
+ end
+
+ defp get_docs(module, name) do
+ {:docs_v1, _anno, _lang, _format, _module_doc, mod_meta, mod_docs} = Code.fetch_docs(module)
+
+ {_, _, _, docs, meta} =
+ Enum.find(mod_docs, fn
+ {{:function, ^name, _}, _, _, _, _} -> true
+ _ -> false
+ end)
+
+ if docs == :none do
+ :error
+ else
+ docs = Map.get(docs, "en", "")
+
+ [summary | _] = String.split(docs, ~r/\n\s*\n/, parts: 2)
+
+ {:ok, {mod_meta, summary, docs, meta}}
+ end
+ end
+
+ defp build_parameters(%{parameters: params}) do
+ for {name, options} <- params do
+ {location, options} = Keyword.pop(options, :in, :query)
+ {type, options} = Keyword.pop(options, :type, :string)
+ {description, options} = Keyword.pop(options, :description, :string)
+
+ Operation.parameter(name, location, type, description, options)
+ end
+ end
+
+ defp build_parameters(_), do: []
+
+ defp build_responses(%{responses: responses}) do
+ for {status, {description, mime, schema}} <- responses, into: %{} do
+ {Plug.Conn.Status.code(status), Operation.response(description, mime, schema)}
+ end
+ end
+
+ defp build_responses(_), do: []
+
+ defp build_request_body(%{body: {name, mime, schema}}),
+ do: Operation.request_body(name, mime, schema)
+
+ defp build_request_body(_), do: nil
+end
diff --git a/lib/open_api_spex/operation.ex b/lib/open_api_spex/operation.ex
index 9009701..e1f8d69 100644
--- a/lib/open_api_spex/operation.ex
+++ b/lib/open_api_spex/operation.ex
@@ -1,233 +1,233 @@
defmodule OpenApiSpex.Operation do
@moduledoc """
Defines the `OpenApiSpex.Operation.t` type.
"""
alias OpenApiSpex.{
Callback,
ExternalDocumentation,
MediaType,
Operation,
Parameter,
Reference,
RequestBody,
Response,
Responses,
Schema,
SecurityRequirement,
Server,
}
@enforce_keys :responses
defstruct tags: [],
summary: nil,
description: nil,
externalDocs: nil,
operationId: nil,
parameters: [],
requestBody: nil,
responses: nil,
callbacks: %{},
deprecated: false,
security: nil,
servers: nil
@typedoc """
[Operation Object](https://swagger.io/specification/#operationObject)
Describes a single API operation on a path.
"""
@type t :: %__MODULE__{
tags: [String.t()],
summary: String.t() | nil,
description: String.t() | nil,
externalDocs: ExternalDocumentation.t() | nil,
operationId: String.t() | nil,
parameters: [Parameter.t() | Reference.t()],
requestBody: RequestBody.t() | Reference.t() | nil,
responses: Responses.t(),
callbacks: %{String.t() => Callback.t() | Reference.t()},
deprecated: boolean,
security: [SecurityRequirement.t()] | nil,
servers: [Server.t()] | nil
}
@doc """
Constructs an Operation struct from the plug and opts specified in the given route
"""
- @spec from_route(PathItem.route) :: t
+ @spec from_route(PathItem.route()) :: t | nil
def from_route(route)
def from_route(route = %_{}) do
route
|> Map.from_struct()
|> from_route()
end
def from_route(%{plug: plug, plug_opts: opts}) do
from_plug(plug, opts)
end
def from_route(%{plug: plug, opts: opts}) do
from_plug(plug, opts)
end
@doc """
Constructs an Operation struct from plug module and opts
"""
- @spec from_plug(module, any) :: t
+ @spec from_plug(module, any) :: t | nil
def from_plug(plug, opts) do
plug.open_api_operation(opts)
end
@doc """
Shorthand for constructing a Parameter name, location, type, description and optional examples
"""
@spec parameter(atom, Parameter.location, Reference.t | Schema.t | atom, String.t, keyword) :: Parameter.t
def parameter(name, location, type, description, opts \\ []) do
params =
[name: name, in: location, description: description, required: location == :path]
|> Keyword.merge(opts)
Parameter
|> struct(params)
|> Parameter.put_schema(type)
end
@doc """
Shorthand for constructing a RequestBody with description, media_type, schema and optional examples
"""
@spec request_body(String.t, String.t, (Schema.t | Reference.t | module), keyword) :: RequestBody.t
def request_body(description, media_type, schema_ref, opts \\ []) do
%RequestBody{
description: description,
content: %{
media_type => %MediaType{
schema: schema_ref,
example: opts[:example],
examples: opts[:examples]
}
},
required: opts[:required] || false
}
end
@doc """
Shorthand for constructing a Response with description, media_type, schema and optional examples
"""
@spec response(String.t, String.t, (Schema.t | Reference.t | module), keyword) :: Response.t
def response(description, media_type, schema_ref, opts \\ []) do
%Response{
description: description,
content: %{
media_type => %MediaType {
schema: schema_ref,
example: opts[:example],
examples: opts[:examples]
}
}
}
end
@doc """
Cast params to the types defined by the schemas of the operation parameters and requestBody
"""
@spec cast(Operation.t, Conn.t, String.t | nil, %{String.t => Schema.t}) :: {:ok, Plug.Conn.t} | {:error, String.t}
def cast(operation = %Operation{}, conn = %Plug.Conn{}, content_type, schemas) do
parameters = Enum.filter(operation.parameters || [], fn p -> Map.has_key?(conn.params, Atom.to_string(p.name)) end)
with :ok <- check_query_params_defined(conn, operation.parameters),
{:ok, parameter_values} <- cast_parameters(parameters, conn.params, schemas),
{:ok, body} <- cast_request_body(operation.requestBody, conn.body_params, content_type, schemas) do
{:ok, %{conn | params: parameter_values, body_params: body}}
end
end
@spec check_query_params_defined(Conn.t, list | nil) :: :ok | {:error, String.t}
defp check_query_params_defined(%Plug.Conn{} = conn, defined_params) when is_nil(defined_params) do
case conn.query_params do
%{} -> :ok
_ -> {:error, "No query parameters defined for this operation"}
end
end
defp check_query_params_defined(%Plug.Conn{} = conn, defined_params) when is_list(defined_params) do
defined_query_params = for param <- defined_params, param.in == :query, into: MapSet.new(), do: to_string(param.name)
case validate_parameter_keys(Map.keys(conn.query_params), defined_query_params) do
{:error, param} -> {:error, "Undefined query parameter: #{inspect(param)}"}
:ok -> :ok
end
end
@spec validate_parameter_keys([String.t], MapSet.t) :: {:error, String.t} | :ok
defp validate_parameter_keys([], _defined_params), do: :ok
defp validate_parameter_keys([param|params], defined_params) do
case MapSet.member?(defined_params, param) do
false -> {:error, param}
_ -> validate_parameter_keys(params, defined_params)
end
end
@spec cast_parameters([Parameter.t], map, %{String.t => Schema.t}) :: {:ok, map} | {:error, String.t}
defp cast_parameters([], _params, _schemas), do: {:ok, %{}}
defp cast_parameters([p | rest], params = %{}, schemas) do
with {:ok, cast_val} <- Schema.cast(Parameter.schema(p), params[Atom.to_string(p.name)], schemas),
{:ok, cast_tail} <- cast_parameters(rest, params, schemas) do
{:ok, Map.put_new(cast_tail, p.name, cast_val)}
end
end
@spec cast_request_body(RequestBody.t | nil, map, String.t | nil, %{String.t => Schema.t}) :: {:ok, map} | {:error, String.t}
defp cast_request_body(nil, _, _, _), do: {:ok, %{}}
defp cast_request_body(%RequestBody{content: content}, params, content_type, schemas) do
schema = content[content_type].schema
Schema.cast(schema, params, schemas)
end
@doc """
Validate params against the schemas of the operation parameters and requestBody
"""
@spec validate(Operation.t, Conn.t, String.t | nil, %{String.t => Schema.t}) :: :ok | {:error, String.t}
def validate(operation = %Operation{}, conn = %Plug.Conn{}, content_type, schemas) do
with :ok <- validate_required_parameters(operation.parameters || [], conn.params),
parameters <- Enum.filter(operation.parameters || [], &Map.has_key?(conn.params, &1.name)),
:ok <- validate_parameter_schemas(parameters, conn.params, schemas),
:ok <- validate_body_schema(operation.requestBody, conn.body_params, content_type, schemas) do
:ok
end
end
@spec validate_required_parameters([Parameter.t], map) :: :ok | {:error, String.t}
defp validate_required_parameters(parameter_list, params = %{}) do
required =
parameter_list
|> Stream.filter(fn parameter -> parameter.required end)
|> Enum.map(fn parameter -> parameter.name end)
missing = required -- Map.keys(params)
case missing do
[] -> :ok
_ -> {:error, "Missing required parameters: #{inspect(missing)}"}
end
end
@spec validate_parameter_schemas([Parameter.t()], map, %{String.t() => Schema.t()}) ::
:ok | {:error, String.t()}
defp validate_parameter_schemas([], %{} = _params, _schemas), do: :ok
defp validate_parameter_schemas([p | rest], %{} = params, schemas) do
{:ok, parameter_value} = Map.fetch(params, p.name)
with :ok <- Schema.validate(Parameter.schema(p), parameter_value, schemas) do
validate_parameter_schemas(rest, params, schemas)
end
end
@spec validate_body_schema(RequestBody.t | nil, map, String.t | nil, %{String.t => Schema.t}) :: :ok | {:error, String.t}
defp validate_body_schema(nil, _, _, _), do: :ok
defp validate_body_schema(%RequestBody{required: false}, params, _content_type, _schemas) when map_size(params) == 0 do
:ok
end
defp validate_body_schema(%RequestBody{content: content}, params, content_type, schemas) do
content
|> Map.get(content_type)
|> Map.get(:schema)
|> Schema.validate(params, schemas)
end
end
diff --git a/lib/open_api_spex/path_item.ex b/lib/open_api_spex/path_item.ex
index 670513e..51893f9 100644
--- a/lib/open_api_spex/path_item.ex
+++ b/lib/open_api_spex/path_item.ex
@@ -1,74 +1,86 @@
defmodule OpenApiSpex.PathItem do
@moduledoc """
Defines the `OpenApiSpex.PathItem.t` type.
"""
alias OpenApiSpex.{Operation, Server, Parameter, PathItem, Reference}
+
defstruct [
:"$ref",
:summary,
:description,
:get,
:put,
:post,
:delete,
:options,
:head,
:patch,
:trace,
:servers,
:parameters
]
@typedoc """
[Path Item Object](https://swagger.io/specification/#pathItemObject)
Describes the operations available on a single path.
A Path Item MAY be empty, due to ACL constraints.
The path itself is still exposed to the documentation viewer
but they will not know which operations and parameters are available.
"""
@type t :: %__MODULE__{
- "$ref": String.t | nil,
- summary: String.t | nil,
- description: String.t | nil,
- get: Operation.t | nil,
- put: Operation.t | nil,
- post: Operation.t | nil,
- delete: Operation.t | nil,
- options: Operation.t | nil,
- head: Operation.t | nil,
- patch: Operation.t | nil,
- trace: Operation.t | nil,
- servers: [Server.t] | nil,
- parameters: [Parameter.t | Reference.t] | nil
- }
+ "$ref": String.t() | nil,
+ summary: String.t() | nil,
+ description: String.t() | nil,
+ get: Operation.t() | nil,
+ put: Operation.t() | nil,
+ post: Operation.t() | nil,
+ delete: Operation.t() | nil,
+ options: Operation.t() | nil,
+ head: Operation.t() | nil,
+ patch: Operation.t() | nil,
+ trace: Operation.t() | nil,
+ servers: [Server.t()] | nil,
+ parameters: [Parameter.t() | Reference.t()] | nil
+ }
@typedoc """
Represents a route from in a Plug/Phoenix application.
Eg from the generated `__routes__` function in a Phoenix.Router module.
"""
@type route ::
- %{verb: atom, plug: atom, opts: any} |
- %{verb: atom, plug: atom, plug_opts: any}
+ %{verb: atom, plug: atom, opts: any}
+ | %{verb: atom, plug: atom, plug_opts: any}
@doc """
Builds a PathItem struct from a list of routes that share a path.
"""
@spec from_routes([route]) :: nil | t
def from_routes(routes) do
Enum.each(routes, fn route ->
Code.ensure_loaded(route.plug)
end)
routes
|> Enum.filter(&function_exported?(&1.plug, :open_api_operation, 1))
|> from_valid_routes()
end
@spec from_valid_routes([route]) :: nil | t
defp from_valid_routes([]), do: nil
+
defp from_valid_routes(routes) do
- struct(PathItem, Enum.map(routes, &{&1.verb, Operation.from_route(&1)}))
+ attrs =
+ routes
+ |> Enum.map(fn route ->
+ case Operation.from_route(route) do
+ nil -> nil
+ op -> {route.verb, op}
+ end
+ end)
+ |> Enum.filter(& &1)
+
+ struct(PathItem, attrs)
end
end
diff --git a/lib/open_api_spex/schema_resolver.ex b/lib/open_api_spex/schema_resolver.ex
index c09eae5..f0770b4 100644
--- a/lib/open_api_spex/schema_resolver.ex
+++ b/lib/open_api_spex/schema_resolver.ex
@@ -1,185 +1,227 @@
defmodule OpenApiSpex.SchemaResolver do
@moduledoc """
Internal module used to resolve `OpenApiSpex.Schema` structs from atoms.
"""
alias OpenApiSpex.{
OpenApi,
Components,
PathItem,
Operation,
Parameter,
Reference,
MediaType,
Schema,
RequestBody,
Response
}
@doc """
Adds schemas to the api spec from the modules specified in the Operations.
Eg, if the response schema for an operation is defined with:
responses: %{
200 => Operation.response("User", "application/json", UserResponse)
}
Then the `UserResponse.schema()` function will be called to load the schema, and
a `Reference` to the loaded schema will be used in the operation response.
See `OpenApiSpex.schema` macro for a convenient syntax for defining schema modules.
"""
- @spec resolve_schema_modules(OpenApi.t) :: OpenApi.t
+ @spec resolve_schema_modules(OpenApi.t()) :: OpenApi.t()
def resolve_schema_modules(spec = %OpenApi{}) do
components = spec.components || %Components{}
schemas = components.schemas || %{}
{paths, schemas} = resolve_schema_modules_from_paths(spec.paths, schemas)
schemas = resolve_schema_modules_from_schemas(schemas)
- %{spec | paths: paths, components: %{components| schemas: schemas}}
+ %{spec | paths: paths, components: %{components | schemas: schemas}}
end
defp resolve_schema_modules_from_paths(paths = %{}, schemas = %{}) do
Enum.reduce(paths, {paths, schemas}, fn {path, path_item}, {paths, schemas} ->
{new_path_item, schemas} = resolve_schema_modules_from_path_item(path_item, schemas)
{Map.put(paths, path, new_path_item), schemas}
end)
end
defp resolve_schema_modules_from_path_item(path = %PathItem{}, schemas) do
path
|> Map.from_struct()
|> Enum.filter(fn {_k, v} -> match?(%Operation{}, v) end)
|> Enum.reduce({path, schemas}, fn {k, operation}, {path, schemas} ->
{new_operation, schemas} = resolve_schema_modules_from_operation(operation, schemas)
{Map.put(path, k, new_operation), schemas}
end)
end
defp resolve_schema_modules_from_operation(operation = %Operation{}, schemas) do
{parameters, schemas} = resolve_schema_modules_from_parameters(operation.parameters, schemas)
- {request_body, schemas} = resolve_schema_modules_from_request_body(operation.requestBody, schemas)
+
+ {request_body, schemas} =
+ resolve_schema_modules_from_request_body(operation.requestBody, schemas)
+
{responses, schemas} = resolve_schema_modules_from_responses(operation.responses, schemas)
- new_operation = %{operation | parameters: parameters, requestBody: request_body, responses: responses}
+
+ new_operation = %{
+ operation
+ | parameters: parameters,
+ requestBody: request_body,
+ responses: responses
+ }
+
{new_operation, schemas}
end
defp resolve_schema_modules_from_parameters(nil, schemas), do: {nil, schemas}
+
defp resolve_schema_modules_from_parameters(parameters, schemas) do
{parameters, schemas} =
Enum.reduce(parameters, {[], schemas}, fn parameter, {parameters, schemas} ->
{new_parameter, schemas} = resolve_schema_modules_from_parameter(parameter, schemas)
{[new_parameter | parameters], schemas}
end)
+
{Enum.reverse(parameters), schemas}
end
- defp resolve_schema_modules_from_parameter(parameter = %Parameter{schema: schema, content: nil}, schemas) do
+ defp resolve_schema_modules_from_parameter(
+ parameter = %Parameter{schema: schema, content: nil},
+ schemas
+ ) do
{schema, schemas} = resolve_schema_modules_from_schema(schema, schemas)
new_parameter = %{parameter | schema: schema}
{new_parameter, schemas}
end
- defp resolve_schema_modules_from_parameter(parameter = %Parameter{schema: nil, content: content = %{}}, schemas) do
+
+ defp resolve_schema_modules_from_parameter(
+ parameter = %Parameter{schema: nil, content: content = %{}},
+ schemas
+ ) do
{new_content, schemas} = resolve_schema_modules_from_content(content, schemas)
{%{parameter | content: new_content}, schemas}
end
+
defp resolve_schema_modules_from_parameter(parameter = %Parameter{}, schemas) do
{parameter, schemas}
end
+
defp resolve_schema_modules_from_parameter(parameter = %Reference{}, schemas) do
{parameter, schemas}
end
defp resolve_schema_modules_from_content(nil, schemas), do: {nil, schemas}
+
defp resolve_schema_modules_from_content(content, schemas) do
Enum.reduce(content, {content, schemas}, fn {mime, media}, {content, schemas} ->
{new_media, schemas} = resolve_schema_modules_from_media_type(media, schemas)
{Map.put(content, mime, new_media), schemas}
end)
end
defp resolve_schema_modules_from_media_type(media = %MediaType{schema: schema}, schemas) do
{schema, schemas} = resolve_schema_modules_from_schema(schema, schemas)
new_media = %{media | schema: schema}
{new_media, schemas}
end
+
defp resolve_schema_modules_from_media_type(media = %MediaType{}, schemas) do
{media, schemas}
end
defp resolve_schema_modules_from_request_body(nil, schemas), do: {nil, schemas}
+
defp resolve_schema_modules_from_request_body(request_body = %RequestBody{}, schemas) do
{content, schemas} = resolve_schema_modules_from_content(request_body.content, schemas)
new_request_body = %{request_body | content: content}
{new_request_body, schemas}
end
+
defp resolve_schema_modules_from_request_body(request_body = %Reference{}, schemas) do
{request_body, schemas}
end
+ defp resolve_schema_modules_from_responses(responses, schemas = %{}) when is_list(responses) do
+ resolve_schema_modules_from_responses(Map.new(responses), schemas)
+ end
+
defp resolve_schema_modules_from_responses(responses = %{}, schemas = %{}) do
Enum.reduce(responses, {responses, schemas}, fn {status, response}, {responses, schemas} ->
{new_response, schemas} = resolve_schema_modules_from_response(response, schemas)
{Map.put(responses, status, new_response), schemas}
end)
end
defp resolve_schema_modules_from_response(response = %Response{}, schemas = %{}) do
{content, schemas} = resolve_schema_modules_from_content(response.content, schemas)
new_response = %{response | content: content}
{new_response, schemas}
end
defp resolve_schema_modules_from_schemas(schemas = %{}) do
Enum.reduce(schemas, schemas, fn {name, schema}, schemas ->
{schema, schemas} = resolve_schema_modules_from_schema(schema, schemas)
Map.put(schemas, name, schema)
end)
end
defp resolve_schema_modules_from_schema(false, schemas), do: {false, schemas}
defp resolve_schema_modules_from_schema(true, schemas), do: {true, schemas}
defp resolve_schema_modules_from_schema(nil, schemas), do: {nil, schemas}
+
defp resolve_schema_modules_from_schema(schema_list, schemas) when is_list(schema_list) do
Enum.map_reduce(schema_list, schemas, &resolve_schema_modules_from_schema/2)
end
+
defp resolve_schema_modules_from_schema(schema, schemas) when is_atom(schema) do
title = schema.schema().title
+
new_schemas =
if Map.has_key?(schemas, title) do
schemas
else
{new_schema, schemas} = resolve_schema_modules_from_schema(schema.schema(), schemas)
Map.put(schemas, title, new_schema)
end
+
{%Reference{"$ref": "#/components/schemas/#{title}"}, new_schemas}
end
+
defp resolve_schema_modules_from_schema(schema = %Schema{}, schemas) do
{all_of, schemas} = resolve_schema_modules_from_schema(schema.allOf, schemas)
{one_of, schemas} = resolve_schema_modules_from_schema(schema.oneOf, schemas)
{any_of, schemas} = resolve_schema_modules_from_schema(schema.anyOf, schemas)
{not_schema, schemas} = resolve_schema_modules_from_schema(schema.not, schemas)
{items, schemas} = resolve_schema_modules_from_schema(schema.items, schemas)
- {additional, schemas} = resolve_schema_modules_from_schema(schema.additionalProperties, schemas)
- {properties, schemas} = resolve_schema_modules_from_schema_properties(schema.properties, schemas)
- schema =
- %{schema |
- allOf: all_of,
+
+ {additional, schemas} =
+ resolve_schema_modules_from_schema(schema.additionalProperties, schemas)
+
+ {properties, schemas} =
+ resolve_schema_modules_from_schema_properties(schema.properties, schemas)
+
+ schema = %{
+ schema
+ | allOf: all_of,
oneOf: one_of,
anyOf: any_of,
not: not_schema,
items: items,
additionalProperties: additional,
properties: properties
- }
+ }
+
{schema, schemas}
end
+
defp resolve_schema_modules_from_schema(ref = %Reference{}, schemas), do: {ref, schemas}
defp resolve_schema_modules_from_schema_properties(nil, schemas), do: {nil, schemas}
+
defp resolve_schema_modules_from_schema_properties(properties, schemas) do
Enum.reduce(properties, {properties, schemas}, fn {name, property}, {properties, schemas} ->
{new_property, schemas} = resolve_schema_modules_from_schema(property, schemas)
{Map.put(properties, name, new_property), schemas}
end)
end
end
diff --git a/test/controller_test.exs b/test/controller_test.exs
new file mode 100644
index 0000000..be2f983
--- /dev/null
+++ b/test/controller_test.exs
@@ -0,0 +1,33 @@
+defmodule OpenApiSpex.ControllerTest do
+ use ExUnit.Case, async: true
+
+ alias OpenApiSpex.Controller, as: Subject
+
+ doctest Subject
+
+ @controller OpenApiSpexTest.UserControllerAnnotated
+
+ describe "Example module" do
+ test "exports open_api_operation/1" do
+ assert function_exported?(@controller, :open_api_operation, 1)
+ end
+
+ test "has defined OpenApiSpex.Operation for show action" do
+ assert %OpenApiSpex.Operation{} = @controller.open_api_operation(:show)
+ end
+
+ test "summary matches 'Endpoint summary'" do
+ assert %{summary: "Endpoint summary"} = @controller.open_api_operation(:show)
+ end
+
+ test "has response for HTTP 200" do
+ assert %{responses: %{200 => _}} = @controller.open_api_operation(:show)
+ end
+
+ test "has parameter `:id`" do
+ assert %{parameters: [param]} = @controller.open_api_operation(:show)
+ assert param.name == :id
+ assert param.required
+ end
+ end
+end
diff --git a/test/support/user_controller_annotated.ex b/test/support/user_controller_annotated.ex
new file mode 100644
index 0000000..06657a2
--- /dev/null
+++ b/test/support/user_controller_annotated.ex
@@ -0,0 +1,19 @@
+defmodule OpenApiSpexTest.UserControllerAnnotated do
+ use OpenApiSpex.Controller
+
+ @moduledoc tags: ["Foo"]
+
+ @doc """
+ Endpoint summary
+
+ More docs
+ """
+ @doc parameters: [
+ id: [in: :path, type: :string, required: true]
+ ]
+ @doc responses: [
+ ok: {"Foo document", "application/json", FooSchema}
+ ]
+ def show, do: :ok
+end
+

File Metadata

Mime Type
text/x-diff
Expires
Wed, Nov 27, 10:57 PM (1 d, 20 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
40768
Default Alt Text
(41 KB)

Event Timeline