Current section
Files
Jump to
Current section
Files
lib/phoenix_spectral.ex
defmodule PhoenixSpectral do
@moduledoc """
Generates OpenAPI 3.0 specifications from Phoenix router and typed controllers.
Controllers that `use PhoenixSpectral.Controller` and define typespecs on their
action functions become the single source of truth for OpenAPI documentation.
## Usage
{:ok, spec} = PhoenixSpectral.generate_openapi(MyAppWeb.Router, %{title: "My API", version: "1.0.0"})
"""
# Records extracted from deps/spectra/include/spectra_internal.hrl.
require Record
Record.defrecordp(
:sp_function_spec,
Record.extract(:sp_function_spec, from_lib: "spectra/include/spectra_internal.hrl")
)
Record.defrecordp(
:sp_literal,
Record.extract(:sp_literal, from_lib: "spectra/include/spectra_internal.hrl")
)
Record.defrecordp(
:sp_tuple,
Record.extract(:sp_tuple, from_lib: "spectra/include/spectra_internal.hrl")
)
Record.defrecordp(
:literal_map_field,
Record.extract(:literal_map_field, from_lib: "spectra/include/spectra_internal.hrl")
)
Record.defrecordp(
:sp_union,
Record.extract(:sp_union, from_lib: "spectra/include/spectra_internal.hrl")
)
@doc """
Generates an OpenAPI 3.0 specification from a Phoenix router module.
Introspects all routes in the router, extracts type information from
controllers, and builds an OpenAPI spec.
## Parameters
- `router` - A Phoenix router module
- `metadata` - Map with API metadata. Required keys:
- `:title` — API title
- `:version` — API version string
Optional keys:
- `:summary` — short one-line summary of the API (appears in `info.summary`)
- `:description` — longer description of the API (appears in `info.description`)
- `:terms_of_service` — URL string for terms of service
- `:contact` — map with `:name`, `:url`, and/or `:email`
- `:license` — map with `:name` and optional `:url` or `:identifier`
- `:servers` — list of server maps, each with `:url` and optional `:description`
## Returns
- `{:ok, iodata}` - Complete OpenAPI 3.0 specification as JSON iodata
- `{:error, errors}` - List of errors if generation fails
## Parameter descriptions via typed type aliases
To add a description to a path or header parameter, define a named type alias
for the parameter's type and annotate it with `spectral description: "..."`:
spectral description: "The user's unique identifier"
@type user_id :: String.t()
@spec show(%{id: user_id()}, %{}, nil) :: {200, %{}, User.t()}
def show(%{id: id}, _headers, _body), do: ...
PhoenixSpectral reads the type's metadata when building the spec and adds the
`description` field to the parameter object in the OpenAPI output.
"""
@spec generate_openapi(module(), map()) :: {:ok, iodata()} | {:error, list()}
def generate_openapi(router, metadata) do
generate_openapi(router, metadata, [])
end
@spec generate_openapi(module(), map(), [:pre_encoded]) ::
{:ok, iodata() | map()} | {:error, list()}
def generate_openapi(router, metadata, options) do
endpoints =
router
|> Phoenix.Router.routes()
|> Enum.filter(&api_route?/1)
|> Enum.map(&route_to_endpoint/1)
result = :spectra_openapi.endpoints_to_openapi(metadata, endpoints, options)
case result do
{:ok, value} -> {:ok, value}
{:error, errors} -> {:error, errors}
end
end
defp api_route?(%{plug: plug}) do
Code.ensure_loaded(plug)
function_exported?(plug, :__spectra_type_info__, 0)
end
defp route_to_endpoint(%{verb: verb, path: path, plug: controller, plug_opts: action}) do
{path_args_type, query_params_type, headers_type, body_type, return_type} =
extract_handler_type(controller, action)
Spectral.OpenAPI.endpoint(verb, phoenix_path_to_openapi_path(path), controller, action, 5)
|> maybe_add_request_body(verb, controller, body_type)
|> add_header_parameters(controller, headers_type)
|> add_query_parameters(controller, query_params_type)
|> add_path_parameters(controller, path_args_type)
|> add_responses(controller, extract_responses(return_type))
end
defp maybe_add_request_body(endpoint, verb, controller, body_type) do
if http_method_supports_body?(verb) do
Spectral.OpenAPI.with_request_body(endpoint, controller, body_type)
else
endpoint
end
end
defp add_responses(endpoint, controller, responses) do
Enum.reduce(responses, endpoint, fn {status, headers_type, body_type}, ep ->
Spectral.OpenAPI.response(status, status_code_description(status))
|> Spectral.OpenAPI.response_with_body(controller, body_type)
|> add_response_headers(controller, headers_type)
|> then(&Spectral.OpenAPI.add_response(ep, &1))
end)
end
defp add_response_headers(response, controller, headers_type) do
type_info = controller.__spectra_type_info__()
fields = PhoenixSpectral.Internal.map_fields(headers_type, type_info)
Enum.reduce(fields, response, fn field, acc ->
literal_map_field(kind: kind, binary_name: binary_name, val_type: val_type) = field
Spectral.OpenAPI.response_with_header(acc, binary_name, controller, %{
required: kind == :exact,
schema: val_type
})
end)
end
defp extract_handler_type(controller, action) do
type_info = controller.__spectra_type_info__()
{:ok,
[
sp_function_spec(
args: [_conn_type, path_args, query_params, headers, body],
return: return_type
)
| _
]} = Spectral.TypeInfo.find_function(type_info, action, 5)
{path_args, query_params, headers, body, return_type}
end
defp extract_responses(sp_union(types: types)) do
Enum.flat_map(types, &extract_single_response/1)
end
defp extract_responses(other) do
extract_single_response(other)
end
defp extract_single_response(sp_tuple(fields: [status_type, headers_type, body_type])) do
sp_literal(value: status) = status_type
[{status, headers_type, body_type}]
end
defp add_header_parameters(endpoint, controller, headers_type) do
type_info = controller.__spectra_type_info__()
fields = PhoenixSpectral.Internal.map_fields(headers_type, type_info)
Enum.reduce(fields, endpoint, fn field, ep ->
literal_map_field(kind: kind, binary_name: binary_name, val_type: val_type) = field
param_spec = %{
name: binary_name,
in: :header,
required: kind == :exact,
schema: val_type
}
Spectral.OpenAPI.with_parameter(ep, controller, param_spec)
end)
end
defp add_query_parameters(endpoint, controller, query_params_type) do
type_info = controller.__spectra_type_info__()
fields = PhoenixSpectral.Internal.map_fields(query_params_type, type_info)
Enum.reduce(fields, endpoint, fn field, ep ->
literal_map_field(kind: kind, binary_name: binary_name, val_type: val_type) = field
param_spec = %{
name: binary_name,
in: :query,
required: kind == :exact,
schema: val_type
}
Spectral.OpenAPI.with_parameter(ep, controller, param_spec)
end)
end
@path_param_regex ~r/:([a-zA-Z_][a-zA-Z0-9_]*)/
defp phoenix_path_to_openapi_path(path) do
Regex.replace(@path_param_regex, path, "{\\1}")
end
defp add_path_parameters(endpoint, controller, path_args_type) do
type_info = controller.__spectra_type_info__()
fields = PhoenixSpectral.Internal.map_fields(path_args_type, type_info)
Enum.reduce(fields, endpoint, fn field, ep ->
literal_map_field(binary_name: binary_name, val_type: val_type) = field
param_spec = %{
name: binary_name,
in: :path,
required: true,
schema: val_type
}
Spectral.OpenAPI.with_parameter(ep, controller, param_spec)
end)
end
defp http_method_supports_body?(:post), do: true
defp http_method_supports_body?(:put), do: true
defp http_method_supports_body?(:patch), do: true
defp http_method_supports_body?(_), do: false
defp status_code_description(200), do: "OK"
defp status_code_description(201), do: "Created"
defp status_code_description(204), do: "No Content"
defp status_code_description(400), do: "Bad Request"
defp status_code_description(401), do: "Unauthorized"
defp status_code_description(403), do: "Forbidden"
defp status_code_description(404), do: "Not Found"
defp status_code_description(409), do: "Conflict"
defp status_code_description(422), do: "Unprocessable Entity"
defp status_code_description(500), do: "Internal Server Error"
defp status_code_description(code), do: "Response #{code}"
end