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 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 components** - See `Rolodex.Schema` for details on how define reusable parameter schemas. See `Rolodex.RequestBody` for details on how to use schemas in your API request body definitions. See `Rolodex.Response` for details on how to use schemas in your API response definitions. See `Rolodex.Headers` for details on how to define reusable headers for your route doc annotations and your responses. - **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 [ auth: :BearerAuth, headers: ["X-Request-ID": uuid, required: true], query_params: [include: :string], path_params: [user_id: :uuid], body: MyRequestBody, responses: %{200 => MyResponse}, metadata: [public: true], tags: ["foo", "bar"] ] @doc "My index action" def index(conn, _), do: conn end # Your request body defmodule MyRequestBody do use Rolodex.RequestBody request_body "MyRequestBody" do desc "A request body" content "application/json" do schema MySchema example :request, %{id: "123", name: "Ada Lovelace"} end end end # Some shared headers for your response defmodule RateLimitHeaders do use Rolodex.Headers headers "RateLimitHeaders" do header "X-Rate-Limited", :boolean, desc: "Have you been rate limited" header "X-Rate-Limit-Duration", :integer end end # Your response defmodule MyResponse do use Rolodex.Response response "MyResponse" do desc "A response" headers RateLimitHeaders content "application/json" do schema MySchema example :response, %{id: "123", name: "Ada Lovelace"} end end end # Your schema defmodule MySchema do use Rolodex.Schema schema "MySchema", desc: "A schema" do field :id, :uuid field :name, :string, desc: "The name" end end # Your Rolodex config defmodule MyConfig do use Rolodex.Config def spec() do [ title: "MyApp", description: "An example", version: "1.0.0", router: MyRouter ] end def auth_spec() do [ BearerAuth: [ type: "http", scheme: "bearer" ] ] end def pipelines_spec() do [ api: [ headers: ["Include-Meta": :boolean] ] ] end end # In mix.exs config :rolodex, module: MyConfig # Then... Application.get_all_env(:rolodex)[:module] |> 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" => %{ "security" => [%{"BearerAuth" => []}], "metadata" => %{"public" => true}, "parameters" => [ %{ "in" => "header", "name" => "X-Request-ID", "required" => true, "schema" => %{ "type" => "string", "format" => "uuid" } }, %{ "in" => "path", "name" => "user_id", "schema" => %{ "type" => "string", "format" => "uuid" } }, %{ "in" => "query", "name" => "include", "schema" => %{ "type" => "string" } } ], "responses" => %{ "200" => %{ "$ref" => "#/components/responses/MyResponse" } }, "requestBody" => %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string", "format" => "uuid"} } }, "tags" => ["foo", "bar"] } } }, "components" => %{ "requestBodies" => %{ "MyRequestBody" => %{ "description" => "A request body", "content" => %{ "application/json" => %{ "schema" => %{ "$ref" => "#/components/schemas/MySchema" }, "examples" => %{ "request" => %{"id" => "123", "name" => "Ada Lovelace"} } } } } }, "responses" => %{ "MyResponse" => %{ "description" => "A response", "headers" => %{ "X-Rate-Limited" => %{ "description" => "Have you been rate limited", "schema" => %{ "type" => "string" } }, "X-Rate-Limit-Duration" => %{ "schema" => %{ "type" => "integer" } } }, "content" => %{ "application/json" => %{ "schema" => %{ "$ref" => "#/components/schemas/MySchema" }, "examples" => %{ "response" => %{"id" => "123", "name" => "Ada Lovelace"} } } } } }, "schemas" => %{ "MySchema" => %{ "type" => "object", "description" => "A schema", "properties" => %{ "id" => %{"type" => "string", "format" => "uuid"}, "name" => %{"type" => "string", "description" => "The name"} } } }, "securitySchemes" => %{ "BearerAuth" => %{ "type" => "http", "scheme" => "bearer" } } } } """ alias Rolodex.{ Config, Field, Headers, RequestBody, Response, Route, Schema } @route_fields_with_refs [:body, :headers, :responses] @ref_types [:headers, :request_body, :response, :schema] @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 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) refs = generate_refs(routes) processor.process(config, routes, refs) 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__() |> Enum.map(&Route.new(&1, config)) |> Enum.reject(&(&1 == nil || Route.matches_filter?(&1, config))) end @doc """ Inspects the request and response parameter data for each `Rolodex.Route`. From these routes, it collects a unique list of `Rolodex.RequestBody`, `Rolodex.Response`, `Rolodex.Headers`, and `Rolodex.Schema` references. The serialized refs will be passed along to a `Rolodex.Processor` behaviour. """ @spec generate_refs([Rolodex.Route.t()]) :: map() def generate_refs(routes) do Enum.reduce( routes, %{schemas: %{}, responses: %{}, request_bodies: %{}, headers: %{}}, &refs_for_route/2 ) end defp refs_for_route(route, all_refs) do route |> unserialized_refs_for_route(all_refs) |> Enum.reduce(all_refs, fn {:schema, ref}, %{schemas: schemas} = acc -> %{acc | schemas: Map.put(schemas, ref, Schema.to_map(ref))} {:response, ref}, %{responses: responses} = acc -> %{acc | responses: Map.put(responses, ref, Response.to_map(ref))} {:request_body, ref}, %{request_bodies: request_bodies} = acc -> %{acc | request_bodies: Map.put(request_bodies, ref, RequestBody.to_map(ref))} {:headers, ref}, %{headers: headers} = acc -> %{acc | headers: Map.put(headers, ref, Headers.to_map(ref))} end) end # Looks at the route fields where users can provide refs that it now needs to # serialize. Performs a DFS on each field to collect any unserialized refs. We # look at both the refs in the maps of data, PLUS refs nested within the # responses/schemas themselves. We recursively traverse this graph until we've # collected all unseen refs for the current context. defp unserialized_refs_for_route(route, all_refs) do serialized_refs = serialized_refs_list(all_refs) route |> Map.take(@route_fields_with_refs) |> 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 |> Field.get_refs() |> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs)) end # Shared schemas, responses, and request bodies can each have nested refs within, # so we recursively collect those. Headers shouldn't have nested refs. defp collect_unserialized_refs(ref, result, serialized_refs) when is_atom(ref) do case Field.get_ref_type(ref) do :schema -> ref |> Schema.get_refs() |> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs)) :response -> ref |> Response.get_refs() |> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs)) :request_body -> ref |> RequestBody.get_refs() |> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs)) :headers -> result :error -> 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 ref_type = Field.get_ref_type(ref) cond do {ref_type, ref} in (Enum.to_list(result) ++ serialized_refs) -> result ref_type in @ref_types -> result = MapSet.put(result, {ref_type, ref}) collect_unserialized_refs(ref, result, serialized_refs) true -> result end end defp serialized_refs_list(%{ schemas: schemas, responses: responses, request_bodies: bodies, headers: headers }) do [schema: schemas, response: responses, request_body: bodies, headers: headers] |> Enum.reduce([], fn {ref_type, refs}, acc -> refs |> Map.keys() |> Enum.map(&{ref_type, &1}) |> Enum.concat(acc) end) end end