defmodule Bureaucrat.SwaggerSlateMarkdownWriter do
@moduledoc """
This markdown writer integrates swagger information and outputs in a slate-friendly markdown format.
It requires that the decoded swagger data be available via Application.get_env(:bureaucrat, :swagger),
eg by passing it as an option to the Bureaucrat.start/1 function.
It can also be configured with the following options, set via the `:writer_opts` argument of Bureaucrat:
* `:plainntext`: If `false`, will not render the plaintext version of request/response examples. Defaults to `true`
"""
alias Bureaucrat.JSON
alias Plug.Conn
# pipeline-able puts
defp puts(file, string) do
IO.puts(file, string)
file
end
@doc """
Writes a list of Plug.Conn records to the given file path.
Each Conn should have request and response data populated,
and the private.phoenix_controller, private.phoenix_action values set for linking to swagger.
"""
def write(records, path) do
{:ok, file} = File.open(path, [:write, :utf8])
swagger = Application.get_env(:bureaucrat, :swagger)
file
|> write_overview(swagger)
|> write_authentication(swagger)
|> write_models(swagger)
records
|> tag_records(swagger)
|> group_records()
|> Enum.each(fn {tag, records_by_operation_id} ->
write_operations_for_tag(file, tag, records_by_operation_id, swagger)
end)
end
@doc """
Writes the document title and api summary description.
This corresponds to the info section of the swagger document.
"""
def write_overview(file, swagger) do
info = swagger["info"]
file
|> puts("""
---
title: #{info["title"]}
search: true
---
# #{info["title"]}
#{info["description"]}
""")
end
@doc """
Writes the authentication details to the given file.
This corresponds to the securityDefinitions section of the swagger document.
"""
def write_authentication(file, %{"security" => security} = swagger) do
file
|> puts("# Authentication\n")
# TODO: Document token based security
Enum.each(security, fn securityRequirement ->
name = Map.keys(securityRequirement) |> List.first()
definition = swagger["securityDefinitions"][name]
file
|> puts("## #{definition["type"]}\n")
|> puts("#{definition["description"]}\n")
end)
file
end
def write_authentication(file, _), do: file
@doc """
Writes the API request/response model schemas to the given file.
This corresponds to the definitions section of the swagger document.
Each top level definition will be written as a table.
Nested objects are flattened out to reduce the number of tables being produced.
"""
def write_models(file, swagger) do
puts(file, "# Models\n")
Enum.each(swagger["definitions"], fn definition ->
write_model(file, swagger, definition)
end)
file
end
@doc """
Writes a single API model schema to the given file.
Most of the work is delegated to the write_model_properties/3 recurive function.
The example json is output before the table just so slate will align them.
"""
def write_model(file, swagger, {name, model_schema}) do
file
|> puts("## #{name}\n")
|> puts("#{model_schema["description"]}")
|> write_model_example(model_schema)
|> puts("|Property|Description|Type|Required|")
|> puts("|--------|-----------|----|--------|")
|> write_model_properties(swagger, model_schema)
|> puts("")
end
def write_model_example(file, %{"example" => example}) do
json = JSON.encode!(example, pretty: true)
file
|> puts("\n```json")
|> puts(json)
|> puts("```\n")
end
def write_model_example(file, _) do
puts(file, "")
end
@doc """
Writes the fields of the given model to file.
prefix is output before each property name to enable nested objects to be flattened.
"""
def write_model_properties(file, swagger, model_schema, prefix \\ "") do
case Map.get(model_schema, "properties") do
nil -> file
properties ->
{objects, primitives} =
properties
|> Enum.split_with(fn {_key, schema} -> schema["type"] == "object" end)
ordered = Enum.concat(primitives, objects)
Enum.each(ordered, fn {property, property_details} ->
{property_details, type} = resolve_type(swagger, property_details)
required? = is_required(property, model_schema)
write_model_property(file, swagger, "#{prefix}#{property}", property_details, type, required?)
end)
file
end
end
def resolve_type(swagger, %{"$ref" => schema_ref}) do
schema_name = String.replace_prefix(schema_ref, "#/definitions/", "")
property_details = swagger["definitions"][schema_name]
type = schema_ref_to_link(schema_ref)
{property_details, type}
end
def resolve_type(_swagger, property_details) do
{property_details, property_details["type"]}
end
def write_model_property(file, swagger, property, property_details, "object", _required?) do
write_model_properties(file, swagger, property_details, "#{property}.")
end
def write_model_property(file, swagger, property, property_details, "array", required?) do
schema = property_details["items"]
# TODO: handle arrays with inline schema
schema_ref = if schema != nil, do: schema["$ref"], else: nil
type = if schema_ref != nil, do: "array(#{schema_ref_to_link(schema_ref)})", else: "array(any)"
write_model_property(file, swagger, property, property_details, type, required?)
end
def write_model_property(file, _swagger, property, property_details, type, required?) do
puts(file, "|#{property}|#{property_details["description"]}|#{type}|#{required?}|")
end
defp is_required(property, %{"required" => required}), do: property in required
defp is_required(_property, _schema), do: false
# Convert a schema reference eg, #/definitions/User to a markdown link
def schema_ref_to_link("#/definitions/" <> type) do
"[#{type}](##{String.downcase(type)})"
end
@doc """
Populate each test record with private.swagger_tag and private.operation_id from swagger.
"""
def tag_records(records, swagger) do
tags_by_operation_id =
for {_path, actions} <- swagger["paths"],
{_action, details} <- actions do
[first_tag | _] = details["tags"]
{details["operationId"], first_tag}
end
|> Enum.into(%{})
Enum.map(records, &tag_record(&1, tags_by_operation_id))
end
@doc """
Tag a single record with swagger tag and operation_id.
"""
def tag_record(conn, tags_by_operation_id) do
operation_id = conn.assigns.bureaucrat_opts[:operation_id]
Conn.put_private(conn, :swagger_tag, tags_by_operation_id[operation_id])
end
@doc """
Group a list of tagged records, first by tag, then by operation_id.
"""
def group_records(records) do
by_tag = Enum.group_by(records, & &1.private.swagger_tag)
Enum.map(by_tag, fn {tag, records_with_tag} ->
by_operation_id = Enum.group_by(records_with_tag, & &1.assigns.bureaucrat_opts[:operation_id])
{tag, by_operation_id}
end)
end
@doc """
Writes the API details and exampels for operations having the given tag.
tag roughly corresponds to a phoenix controller, eg "Users"
records_by_operation_id are the examples collected during tests, grouped by operationId (Controller.action)
"""
def write_operations_for_tag(file, tag, records_by_operation_id, swagger) do
tag_details = swagger["tags"] |> Enum.find(&(&1["name"] == tag))
file
|> puts("# #{tag}\n")
|> puts("#{tag_details["description"]}\n")
Enum.each(records_by_operation_id, fn {operation_id, records} ->
write_action(file, operation_id, records, swagger)
end)
file
end
@doc """
Writes all examples of a given operation (Controller action) to file.
"""
def write_action(file, operation_id, records, swagger) do
details = find_operation_by_id(swagger, operation_id)
puts(file, "## #{details["summary"]}\n")
# write examples before params/schemas to get correct alignment in slate
Enum.each(records, &write_example(file, &1))
file
|> puts("#{details["description"]}\n")
|> write_request(details)
|> write_parameters(swagger, details)
|> write_responses(details)
end
@doc """
Find the details of an API operation in swagger by operationId
"""
def find_operation_by_id(swagger, operation_id) do
Enum.flat_map(swagger["paths"], fn {path, actions} ->
Enum.map(actions, fn {action, details} ->
details
|> Map.put("action", action)
|> Map.put("path", path)
end)
end)
|> Enum.find(fn details ->
details["operationId"] == operation_id
end)
end
@doc """
Writes the request method and path
"""
def write_request(file, %{"action" => action, "path" => path}) do
file
|> puts("### Request\n")
|> puts("`#{String.upcase(action)} #{path}`")
end
@doc """
Writes the parameters table for given swagger operation to file.
Uses the vendor extension "x-example" to provide example of each parameter.
TODO: detailed schema validation rules aren't shown yet (min/max/regex/etc...)
"""
def write_parameters(file, swagger, _ = %{"parameters" => params}) when length(params) > 0 or map_size(params) > 0 do
file
|> puts("### Parameters\n")
|> puts("| Parameter | Description | In |Type | Required | Default | Example |")
|> puts("|-------------|-------------|----|----------|----------|---------|---------|")
Enum.each(params, fn param ->
enriched_param = resolve_schema_type(swagger, param)
content =
["name", "description", "in", "type", "required", "default", "x-example"]
|> Enum.map(&enriched_param[&1])
|> Enum.map(&encode_parameter_table_cell/1)
|> Enum.join("|")
puts(file, "|#{content}|")
end)
puts(file, "")
end
def write_parameters(file, _swagger, _), do: file
def resolve_schema_type(swagger, %{"schema" => schema} = param) do
{_def, type} = resolve_type(swagger, schema)
Map.put(param, "type", type)
end
def resolve_schema_type(_swagger, param), do: param
# Encode parameter table cell values as strings, using json library to convert lists/maps
defp encode_parameter_table_cell(param) when is_map(param) or is_list(param), do: JSON.encode!(param)
defp encode_parameter_table_cell(param), do: to_string(param)
@doc """
Writes the responses table for given swagger operation to file.
Swagger only allows a single description per status code, which can be limiting
when trying to describe all possible error responses. To work around this, add
markdown links into the description.
"""
def write_responses(file, swagger_operation) do
file
|> puts("### Responses\n")
|> puts("| Status | Description | Schema |")
|> puts("|--------|-------------|--------|")
Enum.each(swagger_operation["responses"], fn {status, response} ->
ref = get_in(response, ["schema", "$ref"])
schema = if ref, do: schema_ref_to_link(ref), else: ""
puts(file, "|#{status} | #{response["description"]} | #{schema}|")
end)
end
@doc """
Writes a single request/response example to file
"""
def write_example(file, record) do
path =
case record.query_string do
"" -> record.request_path
str -> "#{record.request_path}?#{str}"
end
plaintext = Keyword.get(config(), :plaintext, true)
# Request with path and headers
if plaintext do
file
|> puts("> #{record.assigns.bureaucrat_desc}\n")
|> puts("```plaintext")
|> puts("#{record.method} #{path}")
|> write_headers(record.req_headers)
|> puts("```\n")
end
# Request Body if applicable
unless record.body_params == %{} do
file
|> puts("```json")
|> puts("#{JSON.encode!(record.body_params, pretty: true)}")
|> puts("```\n")
end
# Response with status and headers
file
|> puts("> Response\n")
if plaintext do
file
|> puts("```plaintext")
|> puts("#{record.status}")
|> write_headers(record.resp_headers)
|> puts("```\n")
end
# Response body
case Enum.find_value(record.resp_headers, fn header ->
header = Tuple.to_list(header)
header |> List.first() == "content-type" &&
header |> List.last() =~ ~r/text\/html(;.*)?\z/
end) do
nil ->
# If the response body is not HTML, format it as JSON file
file
|> puts("```json")
|> puts("#{format_resp_body(record.resp_body)}")
|> puts("```\n")
_ ->
# Assume body is HTML
file
|> puts("```html")
|> puts(record.resp_body)
|> puts("```\n")
end
end
@doc """
Write the list of request/response headers
"""
def write_headers(file, headers) do
Enum.each(headers, fn {header, value} ->
puts(file, "#{header}: #{value}")
end)
file
end
@doc """
Pretty-print a JSON response, handling body correctly
"""
def format_resp_body(string) do
case string do
"" -> ""
_ -> string |> JSON.decode!() |> JSON.encode!(pretty: true)
end
end
defp config, do: Application.get_env(:bureaucrat, :writer_opts, [])
end