Created
January 12, 2023 19:52
-
-
Save AndrewDryga/d80e055c06804c9e025fc792deb55c9d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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