Current section

Files

Jump to
rolodex lib rolodex.ex
Raw

lib/rolodex.ex

defmodule Rolodex do
@moduledoc """
Rolodex generates documentation for your Phoenix API.
Rolodex inspects a Phoenix Router and transforms the `@doc` annotations on your
controller actions into documentation data in the format of your choosing.
`Rolodex.run/1` encapsulates the full documentation generation process. When
invoked, it will:
1) Traverse your Phoenix Router
2) Collect documentation data for the API endpoints exposed by your router
3) Serialize the data into a format of your choosing (e.g. Swagger JSON)
4) Write the serialized data out to a destination of your choosing.
Rolodex can be configured in the `config/` files for your Phoenix project. See
`Rolodex.Config` for more details on configuration options.
## Features and resources
- **Reusable parameter schemas** - See `Rolodex.Schema` for details on how to
write reusable schemas for request and response parameters in your API.
- **Structured annotations** - See `Rolodex.Route` for details on how to format
annotations on your API route action functions for the Rolodex parser to handle
- **Generic serialization** - The `Rolodex.Processor` behaviour encapsulates
the basic steps needed to serialize API metadata into documentation. Rolodex
ships with a valid Swagger JSON processor (see: `Rolodex.Processors.Swagger`)
- **Generic writing** - The `Rolodex.Writer` behaviour encapsulates the basic
steps needed to write out formatted docs. Rolodex ships with a file writer (
see: `Rolodex.Writers.FileWriter`)
## High level example
# Your Phoenix router
defmodule MyRouter do
pipeline :api do
plug MyPlug
end
scope "/api" do
pipe_through [:api]
get "/test", MyController, :index
end
end
# Your controller
defmodule MyController do
@doc [
headers: ["X-Request-ID": uuid],
body: [id: :uuid],
query_params: [include: :string],
path_params: [user_id: :uuid],
responses: %{200 => MyResponse},
metadata: [public: true],
tags: ["foo", "bar"]
]
@doc "My index action"
def index(conn, _), do: conn
end
# Your response schema
defmodule MyResponse do
use Rolodex.Schema
schema "MyResponse", desc: "A response" do
field :id, :uuid
field :name, :string, desc: "The response name"
end
end
# In mix.exs
config :rolodex,
title: "MyApp",
description: "An example",
version: "1.0.0",
router: MyRouter,
pipelines: [
api: [
headers: ["Include-Meta": :boolean]
]
]
# Then...
Application.get_all_env(:rolodex)
|> Rolodex.Config.new()
|> Rolodex.run()
# The JSON written out to file should look like
%{
"openapi" => "3.0.0",
"info" => %{
"title" => "MyApp",
"description" => "An example",
"version" => "1.0.0"
},
"paths" => %{
"/api/test" => %{
"get" => %{
"metadata" => %{"public" => true},
"parameters" => [
%{
"in" => "header",
"name" => "X-Request-ID",
"schema" => %{
"type" => "string",
"format" => "uuid"
}
},
%{
"in" => "path",
"name" => "user_id",
"schema" => %{
"type" => "string",
"format" => "uuid"
}
},
%{
"in" => "query",
"name" => "include",
"schema" => %{
"type" => "string"
}
}
],
"responses" => %{
"200" => %{
"content" => %{
"application/json" => %{
"schema" => %{
"ref" => "#/components/schemas/MyResponse"
}
}
}
}
},
"requestBody" => %{
"type" => "object",
"properties" => %{
"id" => %{"type" => "string", "format" => "uuid"}
}
},
"tags" => ["foo", "bar"]
}
}
},
"components" => %{
"schemas" => %{
"MyResponse" => %{
"type" => "object",
"description" => "A response",
"properties" => %{
"id" => %{"type" => "string", "format" => "uuid"},
"name" => %{"type" => "string", "description" => "The response name"}
}
}
}
}
}
"""
alias Rolodex.{
Config,
Route,
Schema
}
@route_fields_with_schemas [:body, :headers, :path_params, :query_params, :responses]
@doc """
Runs Rolodex and writes out documentation to the specified destination
"""
@spec run(Rolodex.Config.t()) :: :ok | {:error, any()}
def run(config) do
generate_documentation(config)
|> write(config)
end
defp write(processed, %Config{writer: writer} = config) do
writer = Map.get(writer, :module)
with {:ok, device} <- writer.init(config),
:ok <- writer.write(device, processed),
:ok <- writer.close(device) do
:ok
else
err ->
IO.puts("Failed to write docs with error:")
IO.inspect(err)
end
end
@doc """
Generates a list of route docs and a map of response schemas. Passes both into
the configured processor to generate the documentation JSON to be written to
file.
"""
@spec generate_documentation(Rolodex.Config.t()) :: String.t()
def generate_documentation(%Config{processor: processor} = config) do
routes = generate_routes(config)
schemas = generate_schemas(routes)
processor.process(config, routes, schemas)
end
@doc """
Inspects the Phoenix Router provided in your `Rolodex.Config`. Iterates
through the list of routes to generate a `Rolodex.Route` for each. It will
filter out any route(s) that match the filter(s) you provide in your config.
"""
@spec generate_routes(Rolodex.Config.t()) :: [Rolodex.Route.t()]
def generate_routes(%Config{router: router} = config) do
router.__routes__()
|> Flow.from_enumerable()
|> Flow.map(&Route.new(&1, config))
|> Flow.reject(&(&1 == nil || Route.matches_filter?(&1, config)))
|> Enum.to_list()
end
@doc """
Inspects the request and response parameter data for each `Rolodex.Route`.
From these routes, it collects a unique list of `Rolodex.Schema` references,
and serializes each via `Rolodex.Schema.to_map/1`. The serialized schemas will
be passed along to a `Rolodex.Processor` behaviour.
"""
@spec generate_schemas([Rolodex.Route.t()]) :: map()
def generate_schemas(routes) do
routes
|> Flow.from_enumerable()
|> Flow.reduce(fn -> %{} end, &schemas_for_route/2)
|> Map.new()
end
defp schemas_for_route(route, schemas) do
unserialized_refs_for_route(route, schemas)
|> Enum.reduce(schemas, fn ref, acc ->
Map.put(acc, ref, Schema.to_map(ref))
end)
end
# Looks at the route fields where users can provide `Rolodex.Schema` refs
# that it now needs to serialize. Performs a DFS on each field to collect any
# unserialized schema refs. We look at both the refs in the maps of data, PLUS
# refs nested within the schemas themselves. We recursively traverse this graph
# until we've collected all unseen refs for the current context.
defp unserialized_refs_for_route(route, schemas) do
# List of already serialized Rolodex.Schema refs in the route
serialized_refs = Map.keys(schemas)
route
|> Map.take(@route_fields_with_schemas)
|> Enum.reduce(MapSet.new(), fn {_, field}, acc ->
collect_unserialized_refs(field, acc, serialized_refs)
end)
|> Enum.to_list()
end
defp collect_unserialized_refs(field, result, serialized_refs) when is_map(field) do
field
|> Schema.get_refs()
|> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs))
end
defp collect_unserialized_refs(ref, result, serialized_refs) when is_atom(ref) do
case Schema.is_schema_module?(ref) do
true ->
ref
|> Schema.get_refs()
|> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs))
false ->
result
end
end
defp collect_unserialized_refs(_, acc, _), do: acc
# If the current schema ref is unserialized, add to the MapSet of unserialized
# refs, and then continue the recursive traversal
defp collect_ref(ref, result, serialized_refs) do
seen_refs = Enum.to_list(result) ++ serialized_refs
case ref in seen_refs do
true ->
result
false ->
result = MapSet.put(result, ref)
collect_unserialized_refs(ref, result, serialized_refs)
end
end
end