Skip to content

Commit

Permalink
Merge pull request #34 from cilim/feature/api-blueprint-support
Browse files Browse the repository at this point in the history
Implement APIBlueprintWriter
  • Loading branch information
OpakAlex authored Apr 25, 2018
2 parents 682965a + 40df503 commit ed6a21c
Show file tree
Hide file tree
Showing 6 changed files with 254 additions and 2 deletions.
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ named like the output file with a `_intro` or `_INTRO` suffix (before `.md`, if
* `web/controllers/README` -> `web/controllers/README_INTRO`
* `web/controllers/readme.md` -> `web/controllers/readme_intro.md`

Currently only supported by the (default) `Bureaucrat.MarkdownWriter`.
Currently the supported writers are the default `Bureaucrat.MarkdownWriter` and `Bureaucrat.ApiBlueprintWriter`.

Documenting Phoenix Channels
----------------------------
Expand Down Expand Up @@ -173,6 +173,39 @@ Run your application with `mix phoenix.server` and visit `http://localhost:4000/

For a full example see the `examples/swagger_demo` project.

API Blueprint support
---------------------------

Bureaucrat also supports generating markdown files that are formatted in the [API Blueprint](https://apiblueprint.org/) syntax.
Simply set the `Bureaucrat.ApiBlueprintWriter` in your configuration file and run the usual:

```
DOC=1 mix test
```

After the markdown file has been successfully generated you can use [aglio](https://github.com/danielgtaylor/aglio) to produce the html file:

```
aglio -i web/controllers/api/v1/documentation.md -o web/controllers/api/v1/documentation.html
```

### API Blueprint usage note
If you're piping through custom plugs than can prevent the HTTP requests to land in the controllers (authentication, authorization) and you want to document these cases you'll need the `plug_doc()` helper:

```
describe "unauthenticated user" do
test "GET all items", %{conn: conn} do
conn
|> get(item_path(conn, :index))
|> plug_doc(module: __MODULE__, action: :index)
|> doc()
|> assert_unauthenticated()
end
end
```

Without the `plug_doc()` helper Bureaucrat doesn't know the `phoenix_controller` (since the request never landed in the controller) and an error is raised: `** (RuntimeError) GET all items (/api/v1/items) doesn't have required :phoenix_controller key. Have you forgotten to plug_doc()?`

Configuration
-------------

Expand Down Expand Up @@ -200,4 +233,5 @@ be written to `web/controllers/api/v1/README.md`.
* `:titles`: Allows you to specify explicit titles for some of your modules.
For example `[{YourApp.Api.V1.UserController, "API /v1/users"}]` will
change the title (Table of Contents entry and heading) for this controller.
* `:prefix`: Allows you to remove the prefix of the test module names
* `:env_var`: The environment variable used as a flag to trigger doc generation.
170 changes: 170 additions & 0 deletions lib/bureaucrat/api_blueprint_writer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
defmodule Bureaucrat.ApiBlueprintWriter do
def write(records, path) do
file = File.open!(path, [:write, :utf8])
records = group_records(records)
title = Application.get_env(:bureaucrat, :title)
puts(file, "# #{title}\n\n")
write_api_doc(records, file)
end

defp write_api_doc(records, file) do
Enum.each(records, fn {controller, actions} ->
%{request_path: path} = Enum.at(actions, 0) |> elem(1) |> List.first()
puts(file, "\n# Group #{controller}")
puts(file, "## #{controller} [#{path}]")

Enum.each(actions, fn {action, records} ->
write_action(action, controller, Enum.reverse(records), file)
end)
end)

puts(file, "")
end

defp write_action(action, controller, records, file) do
test_description = "#{controller} #{action}"
record_request = Enum.at(records, 0)
method = record_request.method

file |> puts("### #{test_description} [#{method} #{anchor(record_request)}]")

write_parameters(record_request.path_params, file)

Enum.each(records, &write_example(&1, file))
end

defp write_example(record, file) do
path =
case record.query_string do
"" -> record.request_path
str -> "#{record.request_path}?#{str}"
end

file
|> puts("\n\n+ Request #{record.assigns.bureaucrat_desc}")
|> puts("**#{record.method}**  `#{path}`\n")

write_request_headers(record.req_headers, file)
write_body_params(record.body_params, file)

file
|> puts("\n+ Response #{record.status}\n")

write_response_headers(record.resp_headers, file)

file
|> puts(indent_lines(4, "+ Body\n"))
|> puts(indent_lines(12, format_resp_body(record.resp_body)))
end

defp write_parameters(_path_params = %{}, _file), do: nil

defp write_parameters(path_params, file) do
file |> puts("\n+ Parameters\n#{formatted_params(path_params)}")

Enum.each(path_params, fn {param, value} ->
puts(file, indent_lines(12, "#{param}: #{value}"))
end)

file
end

defp write_request_headers(_request_headers = [], _file), do: nil

defp write_request_headers(request_headers, file) do
file |> puts(indent_lines(4, "+ Headers\n"))

Enum.each(request_headers, fn {header, value} ->
puts(file, indent_lines(12, "#{header}: #{value}"))
end)

file
end

defp write_body_params(_body_params = %{}, _file), do: nil

defp write_body_params(body_params, file) do
file
|> puts(indent_lines(4, "+ Body\n"))
|> puts(indent_lines(12, format_body_params(body_params)))
end

defp write_response_headers(_response_headers = [], _file), do: nil

defp write_response_headers(response_headers, file) do
file |> puts(indent_lines(4, "+ Headers\n"))

Enum.each(response_headers, fn {header, value} ->
puts(file, indent_lines(12, "#{header}: #{value}"))
end)

file
end

def format_body_params(params) do
{:ok, json} = Poison.encode(params, pretty: true)
json
end

defp format_resp_body("") do
""
end

defp format_resp_body(string) do
{:ok, struct} = Poison.decode(string)
{:ok, json} = Poison.encode(struct, pretty: true)
json
end

def indent_lines(number_of_spaces, string) do
String.split(string, "\n")
|> Enum.map(fn a -> String.pad_leading("", number_of_spaces) <> a end)
|> Enum.join("\n")
end

def formatted_params(uri_params) do
Enum.map(uri_params, &format_param/1) |> Enum.join("\n")
end

def format_param(param) do
" + #{URI.encode(elem(param, 0))}: `#{URI.encode(elem(param, 1))}`"
end

def anchor(record) do
if record.path_params == %{} do
record.request_path
else
([""] ++ Enum.drop(record.path_info, -1) ++ ["{id}"]) |> Enum.join("/")
end
end

defp puts(file, string) do
IO.puts(file, string)
file
end

defp module_name(module) do
module
|> to_string
|> String.split("Elixir.")
|> List.last()
|> controller_name()
end

def controller_name(module) do
prefix = Application.get_env(:bureaucrat, :prefix)

Regex.run(~r/#{prefix}(.+)/, module, capture: :all_but_first)
|> List.first()
|> String.trim("Controller")
|> Inflex.pluralize()
end

defp group_records(records) do
records
|> Enum.group_by(&module_name(&1.private.phoenix_controller))
|> Enum.map(fn {controller_name, records} ->
{controller_name, Enum.group_by(records, & &1.private.phoenix_action)}
end)
end
end
20 changes: 20 additions & 0 deletions lib/bureaucrat/formatter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule Bureaucrat.Formatter do

defp generate_docs do
records = Bureaucrat.Recorder.get_records
validate_records(records)
writer = Application.get_env(:bureaucrat, :writer)
grouped =
records
Expand Down Expand Up @@ -48,4 +49,23 @@ defmodule Bureaucrat.Formatter do
path_for(record, paths, default_path)
end
end

defp validate_records(records) do
records = Enum.map(records, fn record -> {record, record.private} end)

Enum.each(records, fn record ->
case Map.has_key?(elem(record, 1), :phoenix_controller) do
false ->
details = elem(record, 0)

error_message =
"#{details.assigns.bureaucrat_desc} (#{details.request_path}) doesn't have required :phoenix_controller key. Have you forgotten to plug_doc()?"

raise error_message

_ ->
:ok
end
end)
end
end
26 changes: 26 additions & 0 deletions lib/bureaucrat/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,30 @@ defmodule Bureaucrat.Helpers do
group_title_for(mod, paths)
end
end

@doc """
Helper function for adding the phoenix_controller and phoenix_action keys to
the private map of the request that's coming from the test modules.
For example:
test "all items - unauthenticated", %{conn: conn} do
conn
|> get(item_path(conn, :index))
|> plug_doc(module: __MODULE__, action: :index)
|> doc()
|> assert_unauthenticated()
end
The request from this test will never touch the controller that's being tested,
because it is being piped through a plug that authenticates the user and redirects
to another page. In this scenario, we use the plug_doc function.
"""
def plug_doc(conn, module: module, action: action) do
controller_name = module |> to_string |> String.trim("Test")

conn
|> Plug.Conn.put_private(:phoenix_controller, controller_name)
|> Plug.Conn.put_private(:phoenix_action, action)
end
end
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ defmodule Bureaucrat.Mixfile do
{:plug, "~> 1.0"},
{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0"},
{:phoenix, "~> 1.2", optional: true},
{:ex_doc, ">= 0.0.0", only: :dev}
{:ex_doc, ">= 0.0.0", only: :dev},
{:inflex, "~> 1.10.0"}
]
end

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
%{"earmark": {:hex, :earmark, "1.2.0", "bf1ce17aea43ab62f6943b97bd6e3dc032ce45d4f787504e3adf738e54b42f3a", [:mix], []},
"ex_doc": {:hex, :ex_doc, "0.15.0", "e73333785eef3488cf9144a6e847d3d647e67d02bd6fdac500687854dd5c599f", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]},
"inflex": {:hex, :inflex, "1.10.0", "8366a7696e70e1813aca102e61274addf85d99f4a072b2f9c7984054ea1b9d29", [:mix], [], "hexpm"},
"mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], []},
"phoenix": {:hex, :phoenix, "1.2.1", "6dc592249ab73c67575769765b66ad164ad25d83defa3492dc6ae269bd2a68ab", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, optional: false]}, {:plug, "~> 1.1", [hex: :plug, optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.1", "c10ddf6237007c804bf2b8f3c4d5b99009b42eca3a0dfac04ea2d8001186056a", [:mix], []},
Expand Down

0 comments on commit ed6a21c

Please sign in to comment.