defmodule OpenApiSpex.Controller do
@moduledoc ~S'''
Generation of OpenAPI documentation via ExDoc documentation and tags.
Note: For projects using Elixir releases, [there is an issue](https://github.com/open-api-spex/open_api_spex/issues/242) that
potentially breaks OpenApiSpex's integration with your application. Please use `OpenApiSpex.ControllerSpecs` instead.
## Supported OpenAPI fields
### `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 the rest of it
will be used as `description` field.
### `operation_id`
The action's `operation_id` can be set explicitly using a `@doc` tag.
If no `operation_id` is specified, it will default to the action's module path: `Module.Name.function_name`
### `parameters`
Parameters of the endpoint are defined by `:parameters` tag which should be
map or list that is formed as:
```elixir
[
param_name: definition
]
# or
[
structure_definition
]
```
Where `definition` is map or keyword list that accepts the same arguments.
Where `structure_definition` is `OpenApiSpex.Parameter.t()` or `OpenApiSpex.Reference.t()` structure
that accepts the same arguments.
Example:
```elixir
@doc parameters: [
group_id: [in: :path, type: :integer, description: "Group ID", example: 1]
]
```
or
```elixir
@doc parameters: [
%OpenApiSpex.Parameter{
in: :path,
name: :group_id,
description: "Group ID",
schema: %Schema{type: :integer},
required: true,
example: 1
}
]
```
or
```elixir
@doc parameters: [
"$ref": "#/components/parameters/group_id"
# or
%OpenApiSpex.Reference{"$ref": "#/components/parameters/group_id"}
]
```
### `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}
}
```
Or:
```
[
ok: {"Response name", "application/json", schema},
not_found: {"Response name", "application/json", schema}
]
```
If a response has no body, the definition may be simplified further:
```
[
no_content: "Empty response"
]
```
For each key in the key-value list of map, either an HTTP status code can be used or its atom equivalent.
The full set of atom keys are defined in `Plug.Conn.Status.code/1`.
### `requestBody`
Controlled by `:request_body` parameter and is defined as a tuple in form
`{description, mime, schema}` or `{description, mime, schema, opts}` that
matches the arguments of `OpenApiSpex.Operation.request_body/3` or
`OpenApiSpex.Operation.request_body/4`, respectively.
```
@doc request_body: {
"CartUpdateRequest",
"application/vnd.api+json",
CartUpdateRequest,
required: true
}
```
### `security`
Allows specifying the security scheme(s) required for this operation. See
`OpenApiSpex.Operation` and `OpenApiSpex.SecurityRequirement`.
### `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 UserController do
@moduledoc tags: ["Users"]
use MyAppWeb, :controller
use OpenApiSpex.Controller
@doc """
Endpoint summary
Endpoint description...
"""
@doc parameters: [
id: [in: :path, type: :string, required: true]
],
request_body: {"Request body to update User", "application/json", UserUpdateBody, required: true},
responses: [
ok: {"User document", "application/json", UserSchema},
{302, "Redirect", "text/html", EmptyResponse, headers: %{"Location" => %Header{description: "Redirect Location"}}}
]
def update(conn, %{id: id}) do
user_params = conn.body_params
# …
end
end
```
'''
alias OpenApiSpex.{Operation, OperationBuilder}
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
case get_docs(mod, name) do
{:ok, {mod_meta, summary, description, meta}} ->
%Operation{
summary: summary,
externalDocs: OperationBuilder.build_external_docs(meta, mod_meta),
description: description || "",
operationId: OperationBuilder.build_operation_id(meta, mod, name),
parameters: OperationBuilder.build_parameters(meta),
requestBody: OperationBuilder.build_request_body(meta),
responses: OperationBuilder.build_responses(meta),
security: OperationBuilder.build_security(meta, mod_meta),
tags: OperationBuilder.build_tags(meta, mod_meta)
}
_ ->
nil
end
end
defp get_docs(module, name) do
{:docs_v1, _anno, _lang, _format, _module_doc, mod_meta, mod_docs} = Code.fetch_docs(module)
mod_docs
|> Enum.find(mod_docs, fn
{{:function, ^name, _}, _, _, _, _} -> true
_ -> false
end)
|> doc_for_function(module, name, mod_meta)
end
defp doc_for_function({_, _, _, :hidden, _}, _module, _name, _mod_meta), do: nil
defp doc_for_function({_, _, _, docs, meta}, module, name, mod_meta) when is_map(meta) do
cond do
Enum.empty?(meta) ->
IO.warn("No docs found for function #{module}.#{name}/2")
nil
not Map.has_key?(meta, :responses) ->
IO.warn("No responses declaration found for function #{module}.#{name}/2")
nil
true ->
{summary, description} =
case docs do
%{"en" => contents} ->
[summary | maybe_description] = String.split(contents, ~r/\n\s*\n/, parts: 2)
{summary, List.first(maybe_description) || ""}
_ ->
{"", ""}
end
{:ok, {mod_meta, summary, description, meta}}
end
end
defp doc_for_function(_doc_for_function, module, name, _mod_meta) do
IO.warn("Invalid docs declaration found for function #{module}.#{name}/2")
nil
end
end