Skip to content

Instantly share code, notes, and snippets.

@AndrewDryga
Created January 12, 2023 19:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save AndrewDryga/d80e055c06804c9e025fc792deb55c9d to your computer and use it in GitHub Desktop.
Save AndrewDryga/d80e055c06804c9e025fc792deb55c9d to your computer and use it in GitHub Desktop.
defmodule Firezone.OpenAPIDocsWriter do
@keep_req_headers []
@keep_resp_headers ["content-type", "location"]
def write(conns, path) do
file = File.open!(path, [:write, :utf8])
open_api_spec = %{
openapi: "3.0.0",
info: %{
title: "Firezone API",
version: "0.1.0",
contact: %{
name: "Firezone Issue Tracker",
url: "https://github.com/firezone/firezone/issues"
},
license: %{
name: "Apache License 2.0",
url: "https://github.com/firezone/firezone/blob/master/LICENSE"
}
},
components: %{
securitySchemes: %{
api_key: %{
type: "http",
scheme: "bearer",
bearerFormat: "JWT"
}
}
},
paths: build_paths(conns)
}
IO.puts(file, Jason.encode!(open_api_spec, pretty: true))
end
defp build_paths(conns) do
routes = Phoenix.Router.routes(List.first(conns).private.phoenix_router)
conns
|> Enum.group_by(& &1.private.phoenix_controller)
|> Enum.map(fn {controller, conns} ->
{_moduledoc, module_api_doc, function_docs} = fetch_module_docs!(controller)
conns
|> Enum.group_by(& &1.private.phoenix_action)
|> Enum.map(fn {action, conns} ->
{path, verb} = fetch_route!(routes, controller, action)
{path, %{verb => sample_conns(conns, verb, path, module_api_doc, function_docs)}}
end)
|> group_by_pop()
|> Enum.map(fn {key, maps} ->
{key, merge_maps_list(maps)}
end)
|> Enum.into(%{})
end)
|> merge_maps_list()
|> IO.inspect()
end
defp fetch_route!(routes, controller, controller_action) do
%{path: path, verb: verb} =
Enum.find(routes, fn
%{plug: ^controller, plug_opts: ^controller_action} -> true
_other -> false
end)
path = String.replace(path, ~r|:([^/]*)|, "{\\1}")
{path, verb}
end
defp merge_maps_list(maps) do
Enum.reduce(maps, %{}, fn map, acc ->
Map.merge(acc, map)
end)
end
defp group_by_pop(tuples) do
tuples
|> Enum.reduce(%{}, fn {key, value}, acc ->
case acc do
%{^key => existing} -> %{acc | key => [value | existing]}
%{} -> Map.put(acc, key, [value])
end
end)
end
defp fetch_module_docs!(controller) do
case Code.fetch_docs(controller) do
{:docs_v1, _, _, _, moduledoc, %{api_doc: module_api_doc}, function_docs} ->
{get_doc(moduledoc), module_api_doc, function_docs}
{:error, :module_not_found} ->
raise "No module #{controller}"
end
end
defp get_doc(md) when is_map(md), do: Map.get(md, "en")
defp get_doc(_md), do: nil
defp get_function_docs(function_docs, function) do
function_docs
|> Enum.find(fn
{{:function, ^function, _}, _, _, _, _} -> true
{{:function, _function, _}, _, _, _, _} -> false
end)
|> case do
{_, _, _, :none, %{api_doc: api_doc}} ->
{nil, api_doc}
{_, _, _, doc, %{api_doc: api_doc}} ->
{get_doc(doc), api_doc}
{_, _, _, doc, _chunks} ->
{get_doc(doc), %{}}
_other ->
{nil, %{}}
end
end
defp sample_conns([conn | _] = conns, verb, path, module_api_doc, function_docs) do
action = conn.private.phoenix_action
{description, assigns} = get_function_docs(function_docs, conn.private.phoenix_action)
summary = Keyword.get(assigns, :summary, action)
# parameters = Keyword.get(assigns, :parameters, [])
responses =
conns
|> Enum.group_by(& &1.status)
|> Enum.map(fn {status, conns} ->
{status, build_response(conns, module_api_doc, assigns)}
end)
|> Enum.into(%{})
header_params =
for {key, _value} <- conn.req_headers, key in @keep_req_headers do
%{
name: camelize_header_key(key),
in: "header",
required: false,
schema: %{
type: "string"
}
}
end
uri_params =
Regex.scan(~r/{([^}]*)}/, path)
|> Enum.map(fn [_, param] ->
%{
name: param,
in: "path",
required: true,
schema: %{
type: "string"
}
}
end)
request_body =
unless verb == :get or verb == :delete do
%{
required: true,
content: %{"application/json" => %{example: conn.body_params}}
}
end
%{
summary: summary,
parameters: header_params ++ uri_params,
security: [
%{api_key: []}
],
responses: responses
}
|> put_if_not_nil(:description, description)
|> put_if_not_nil(:requestBody, request_body)
end
defp build_response([conn | _], _module_api_doc, _assigns) do
resp_headers =
for {key, _value} <- conn.resp_headers, key in @keep_resp_headers, into: %{} do
{key, %{schema: %{type: "string"}}}
end
content_type =
case Plug.Conn.get_resp_header(conn, "content-type") do
[content_type] ->
content_type
|> String.split(";")
|> List.first()
[] ->
"application/json"
end
%{
description: conn.assigns.bureaucrat_opts[:title] || "Description",
headers: resp_headers,
content: %{
content_type => %{examples: %{example: %{value: body_example(conn.resp_body)}}}
}
}
end
defp body_example(body) do
with {:ok, map} <- Jason.decode(body) do
map
else
_ -> body
end
end
defp camelize_header_key(key) do
key
|> String.split("-")
|> Enum.map(fn
<<first::utf8, rest::binary>> -> String.upcase(<<first::utf8>>) <> rest
other -> other
end)
|> Enum.join("-")
end
defp put_if_not_nil(map, _key, nil), do: map
defp put_if_not_nil(map, key, value), do: Map.put(map, key, value)
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment