├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── mix │ └── tasks │ │ └── phoenix.api_docs.ex ├── phoenix_api_docs.ex └── phoenix_api_docs │ ├── blueprint_writer.ex │ ├── conn_logger.ex │ ├── controller.ex │ ├── formatter.ex │ └── generator.ex ├── mix.exs └── mix.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /doc 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Paul Smoczyk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phoenix Api Docs 2 | 3 | `PhoenixApiDocs` is a library written in the `Elixir` for the [Phoenix framework](http://www.phoenixframework.org/). It lets you generate API documentation in the [API Blueprint](https://apiblueprint.org/) format from annotations in controllers and automated tests. 4 | 5 | 6 | ## Installation 7 | 8 | Add PhoenixApiDocs to your mix.exs dependencies: 9 | 10 | ```elixir 11 | defp deps do 12 | [{:phoenix_api_docs, "~> 0.1.0"}] 13 | end 14 | ``` 15 | 16 | Run `mix deps.get` to fetch the dependencies: 17 | 18 | ``` 19 | $ mix deps.get 20 | ``` 21 | 22 | In your `test/test_helper.exs` start gen server `PhoenixApiDocs.start` for logging requests and configure `ExUnit` to use `PhoenixApiDocs.Formatter`: 23 | 24 | ```elixir 25 | PhoenixApiDocs.start 26 | ExUnit.start(formatters: [ExUnit.CLIFormatter, PhoenixApiDocs.Formatter]) 27 | ``` 28 | 29 | 30 | ## Usage 31 | 32 | Add `api_docs_info` to your `mix.exs`: 33 | 34 | ```elixir 35 | def api_docs_info do 36 | [ 37 | host: "https://api.acme.com", 38 | title: "ACME API", 39 | description: "API requires authorization. All requests must have valid `auth_token`" 40 | ] 41 | end 42 | ``` 43 | 44 | Options: 45 | * `host`: API host. 46 | * `title`: Documentation title (can use Blueprint format). 47 | * `description`: Documentation description (can use Blueprint format). 48 | 49 | Add `PhoenixApiDocs.Controller` to your `phoenix` controller and use `api\3` macro to generate specification for the controller action: 50 | 51 | ```elixir 52 | defmodule App.CommentController do 53 | use App.Web, :controller 54 | use PhoenixApiDocs.Controller 55 | 56 | api :GET, "/posts/:post_id/comments" do 57 | group "Comment" # If not provided, it will be guessed from the controller name (resource name) 58 | title "List comments for specific docs" 59 | description "Optiona description that will be displayed in the documentation" 60 | note "Optional note that will be displayed in the documentation" 61 | parameter :post_id, :integer, :required, "Post ID or slug" 62 | end 63 | def index(conn, %{"post_id" => post_id}) do 64 | ... 65 | end 66 | 67 | api :PUT, "/posts/:post_id/comments" do 68 | title "Update comment" 69 | parameter :post_id, :integer, :required, "Post ID or slug" 70 | end 71 | def update(conn, %{"comment" => comment_params}) do 72 | ... 73 | end 74 | 75 | end 76 | ``` 77 | 78 | API specification options: 79 | 80 | * `method`: HTTP method - GET, POST, PUT, PATCH, DELETE 81 | * `url`: URL route from `phoenix router`` 82 | * `group`: Documentation routes are grouped by a group name (defaults to resource name guessed from the controller name) 83 | * `title`: Title (can use Blueprint format) 84 | * `description`: Description (optional, can use Blueprint format) 85 | * `note`: Note (optional, can use Blueprint format) 86 | * `parameter`: `name, type, required/optional, description` 87 | * required - `parameter :post_id, :integer, :required, "Post ID"` 88 | * optional - `parameter :post_id, :integer, "Post ID"` 89 | 90 | 91 | In your tests select what requests and responses you want to include in the documentation by saving `conn` to `PhoenixApiDocs.ConnLogger`: 92 | 93 | ```elixir 94 | test "list comments for post", %{conn: conn} do 95 | post = insert(:post) 96 | insert_list(5, :comment, post: post) 97 | 98 | conn = get( 99 | conn, 100 | comments_path(conn, :index, post) 101 | ) 102 | 103 | assert json_response(conn, 200) 104 | 105 | PhoenixApiDocs.ConnLogger.save(conn) 106 | end 107 | ``` 108 | 109 | `PhoenixApiDocs.ConnLogger.save` can be also piped: 110 | 111 | ```elixir 112 | conn = get( 113 | conn, 114 | comments_path(conn, :index, post) 115 | ) |> PhoenixApiDocs.ConnLogger.save 116 | end 117 | ``` 118 | 119 | After you run your tests, documentation in an API Blueprint format will be generate in a file `api.apib` 120 | 121 | ``` 122 | $ mix test 123 | ``` 124 | 125 | To generate the documentation in a HTML format use [Aglio renderer](https://github.com/danielgtaylor/aglio) 126 | 127 | ``` 128 | $ npm install aglio -g 129 | 130 | $ mix phoenix.api_docs 131 | ``` 132 | 133 | 134 | ## Configuration 135 | 136 | The configuration options can be setup in `config.exs`: 137 | 138 | ```elixir 139 | config :phoenix_api_docs, 140 | docs_path: "priv/static/docs", 141 | theme: "triple" 142 | ``` 143 | 144 | Config options: 145 | * `docs_path`: Specify the path where the documentation will be generated. If you want to serve the documentation directly from the `phoenix` you can specify `priv/static/docs`. 146 | * `theme`: HTML theme is generated using the [Aglio renderer](https://github.com/danielgtaylor/aglio). 147 | 148 | 149 | ## Common problems 150 | 151 | #### Route is not generated after adding api annotation in the controller 152 | 153 | Please make sure that the route you are using in the annotation matches exactly the route from the `phoenix router` (including params). Run `mix phoenix.routes` and compare the routes. 154 | 155 | ## Tasks to do 156 | 157 | * `raise error` when route that is used in the annotation is not available in the `phoenix router` 158 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | -------------------------------------------------------------------------------- /lib/mix/tasks/phoenix.api_docs.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Phoenix.ApiDocs do 2 | use Mix.Task 3 | 4 | @shortdoc "Generates HTML API Docs from api.apib using Aglio" 5 | 6 | def run(_) do 7 | if System.find_executable("aglio") == nil do 8 | raise "Install Aglio to convert Blueprint API to HTML: \"npm install aglio -g\"" 9 | end 10 | 11 | docs_path = Application.get_env(:phoenix_api_docs, :docs_path, "docs") 12 | docs_theme = Application.get_env(:phoenix_api_docs, :docs_theme, "triple") 13 | project_path = Mix.Project.load_paths |> Enum.at(0) |> String.split("_build") |> Enum.at(0) 14 | path = Path.join(project_path, docs_path) 15 | 16 | System.cmd("aglio", ["--theme-template", docs_theme, "-i", Path.join(path, "api.apib"), "-o", Path.join(path, "index.html")]) 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /lib/phoenix_api_docs.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixApiDocs do 2 | use Application 3 | 4 | def start(_type, []) do 5 | import Supervisor.Spec 6 | 7 | children = [ 8 | worker(PhoenixApiDocs.ConnLogger, []), 9 | ] 10 | 11 | opts = [strategy: :one_for_one, name: PhoenixApiDocs.Supervisor] 12 | Supervisor.start_link(children, opts) 13 | end 14 | 15 | def start(options \\ []) do 16 | Application.start(:phoenix_api_docs) 17 | Enum.each options, fn {k, v} -> 18 | Application.put_env(:phoenix_api_docs, k, v) 19 | end 20 | :ok 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /lib/phoenix_api_docs/blueprint_writer.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixApiDocs.BlueprintWriter do 2 | 3 | def run(api_docs, path) do 4 | filename = Path.join(path, "api.apib") 5 | 6 | File.mkdir_p(path) 7 | File.write(filename, blueprint_text(api_docs)) 8 | end 9 | 10 | defp blueprint_text(api_docs) do 11 | documentation_header = proces_documentation_header(api_docs) 12 | 13 | api_docs.routes 14 | |> Enum.sort_by(fn(route) -> route.group end) 15 | |> Enum.group_by(fn(route) -> route.group end) 16 | |> Enum.to_list 17 | |> Enum.reduce(documentation_header, fn({group_name, group_routes}, docs) -> 18 | docs 19 | <> 20 | """ 21 | # Group #{group_name} 22 | 23 | #{process_routes(group_name, group_routes)} 24 | """ 25 | end) 26 | end 27 | 28 | defp proces_documentation_header(api_docs) do 29 | """ 30 | FORMAT: 1A 31 | HOST: #{api_docs.host} 32 | 33 | # #{api_docs.title} 34 | 35 | #{api_docs.description} 36 | 37 | 38 | """ 39 | end 40 | 41 | defp process_routes(group, routes) do 42 | Enum.reduce routes, "", fn(route, docs) -> 43 | docs 44 | <> 45 | process_header(group, route) 46 | <> 47 | process_note(route) 48 | <> 49 | process_parameters(route) 50 | <> 51 | process_requests(route) 52 | end 53 | end 54 | 55 | defp process_header(group, route) do 56 | path = Regex.replace(~r/:([^\/]+)/, route.path, "{\\1}") 57 | 58 | """ 59 | 60 | ## #{group} [#{path}] 61 | 62 | ### #{route.title} [#{route.method}] 63 | 64 | #{Map.get(route, :description, "")} 65 | 66 | """ 67 | end 68 | 69 | defp process_note(%{note: note}) when is_binary(note) do 70 | """ 71 | 72 | ::: note 73 | #{note} 74 | ::: 75 | 76 | """ 77 | end 78 | 79 | defp process_note(_), do: "" 80 | 81 | defp process_parameters(%{parameters: parameters}) when is_list(parameters) do 82 | docs = 83 | """ 84 | 85 | + Parameters 86 | """ 87 | Enum.reduce parameters, docs, fn(param, docs) -> 88 | required_option = if Map.get(param, :required), do: "required", else: "optional" 89 | 90 | docs 91 | <> 92 | case Map.fetch(param, :example) do 93 | {:ok, example} -> 94 | """ 95 | + #{param.name}: `#{example}` (#{param.type}, #{required_option}) - #{param.description} 96 | """ 97 | :error -> 98 | """ 99 | + #{param.name}: (#{param.type}, #{required_option}) - #{param.description} 100 | """ 101 | end 102 | end 103 | end 104 | 105 | defp process_parameters(_), do: "" 106 | 107 | defp process_requests(%{requests: requests}) when is_list(requests) do 108 | Enum.reduce requests, "", fn(request, docs) -> 109 | docs <> request_body(request) <> response_body(request) 110 | end 111 | end 112 | 113 | defp process_requests(_), do: "" 114 | 115 | defp request_body(request) do 116 | case Map.fetch(request, :body) do 117 | {:ok, body} -> 118 | """ 119 | 120 | + Request json (application/json) 121 | #{body} 122 | """ 123 | :error -> 124 | "" 125 | end 126 | end 127 | 128 | defp response_body(request) do 129 | case Map.fetch(request, :response) do 130 | {:ok, response} -> 131 | """ 132 | 133 | + Response #{response.status} 134 | #{response.body} 135 | """ 136 | :error -> 137 | "" 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/phoenix_api_docs/conn_logger.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixApiDocs.ConnLogger do 2 | use GenServer 3 | 4 | def start_link do 5 | {:ok, _} = GenServer.start_link(__MODULE__, [], name: __MODULE__) 6 | end 7 | 8 | def save(conn) do 9 | GenServer.cast(__MODULE__, {:save, conn}) 10 | conn 11 | end 12 | 13 | def conns do 14 | GenServer.call(__MODULE__, :conns) 15 | end 16 | 17 | def init([]) do 18 | {:ok, []} 19 | end 20 | 21 | def handle_cast({:save, conn}, conns) do 22 | {:noreply, conns ++ [conn]} 23 | end 24 | 25 | def handle_call(:conns, _from, conns) do 26 | {:reply, conns, conns} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/phoenix_api_docs/controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixApiDocs.Controller do 2 | 3 | defmacro __using__(_) do 4 | quote do 5 | import PhoenixApiDocs.Controller, only: [api: 3] 6 | end 7 | end 8 | 9 | @doc """ 10 | api :GET, "/posts/:id" do 11 | group "Posts" 12 | title "Show post" 13 | description "Show post by id" 14 | parameter :id, :integer, :required, "Post ID" 15 | end 16 | """ 17 | defmacro api(method, path, do: block) do 18 | route_method = method |> atom_to_string |> String.upcase 19 | metadata = extract_metadata(block) 20 | group = metadata |> Keyword.get(:group, []) |> List.first 21 | title = metadata |> Keyword.get(:title, ["Action"]) |> List.first 22 | description = metadata |> Keyword.get(:description, []) |> List.first 23 | note = metadata |> Keyword.get(:note, []) |> List.first 24 | parameters = extract_parameters(metadata) 25 | 26 | quote do 27 | def api_doc(unquote(route_method), unquote(path)) do 28 | %{ 29 | group: unquote(group), 30 | title: unquote(title), 31 | description: unquote(description), 32 | note: unquote(note), 33 | method: unquote(route_method), 34 | path: unquote(path), 35 | parameters: unquote(Macro.escape(parameters)) 36 | } 37 | end 38 | end 39 | end 40 | 41 | defp extract_metadata({:__block__, _, data}) do 42 | Enum.map data, fn({name, _line, params}) -> 43 | {name, params} 44 | end 45 | end 46 | 47 | defp extract_parameters(metadata) do 48 | Enum.reduce metadata, [], fn(parameter, list) -> 49 | case parameter do 50 | {:parameter, [name, type, :required, description]} -> 51 | list ++ [%{name: atom_to_string(name), type: atom_to_string(type), required: true, description: description}] 52 | {:parameter, [name, type, :required]} -> 53 | list ++ [%{name: atom_to_string(name), type: atom_to_string(type), required: true, description: ""}] 54 | {:parameter, [name, type, description]} -> 55 | list ++ [%{name: atom_to_string(name), type: atom_to_string(type), required: false, description: description}] 56 | {:parameter, [name, type]} -> 57 | list ++ [%{name: atom_to_string(name), type: atom_to_string(type), required: false, description: ""}] 58 | _ -> 59 | list 60 | end 61 | end 62 | end 63 | 64 | defp atom_to_string(atom_or_string) do 65 | cond do 66 | is_atom(atom_or_string) -> 67 | atom_or_string |> Atom.to_string 68 | true -> 69 | atom_or_string 70 | end 71 | end 72 | 73 | end 74 | -------------------------------------------------------------------------------- /lib/phoenix_api_docs/formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixApiDocs.Formatter do 2 | use GenEvent 3 | 4 | def init(_config) do 5 | {:ok, nil} 6 | end 7 | 8 | def handle_event({:suite_finished, _run_us, _load_us}, nil) do 9 | save_blueprint_file 10 | :remove_handler 11 | end 12 | 13 | def handle_event(_event, nil) do 14 | {:ok, nil} 15 | end 16 | 17 | defp save_blueprint_file do 18 | project_path = Mix.Project.load_paths |> Enum.at(0) |> String.split("_build") |> Enum.at(0) 19 | docs_path = Application.get_env(:phoenix_api_docs, :docs_path, "docs") 20 | path = Path.join(project_path, docs_path) 21 | 22 | api_docs = PhoenixApiDocs.Generator.run 23 | 24 | PhoenixApiDocs.BlueprintWriter.run(api_docs, path) 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/phoenix_api_docs/generator.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixApiDocs.Generator do 2 | 3 | def run do 4 | test_conns = PhoenixApiDocs.ConnLogger.conns 5 | app_module = Mix.Project.get.application |> Keyword.get(:mod) |> elem(0) 6 | router_module = Module.concat([app_module, :Router]) 7 | 8 | %{ 9 | host: Keyword.get(api_docs_info, :host, "http://localhost"), 10 | title: Keyword.get(api_docs_info, :title, "API Documentation"), 11 | description: Keyword.get(api_docs_info, :description, "Enter API description in mix.exs - api_docs_info"), 12 | routes: routes_docs(router_module, test_conns) 13 | } 14 | end 15 | 16 | defp api_docs_info do 17 | case function_exported?(Mix.Project.get, :api_docs_info, 0) do 18 | true -> 19 | Mix.Project.get.api_docs_info 20 | false -> 21 | [] 22 | end 23 | end 24 | 25 | defp routes_docs(router_module, test_conns) do 26 | requests_list = requests(router_module.__routes__, test_conns) 27 | 28 | router_module.__routes__ 29 | |> Enum.filter(fn(route) -> Enum.member?(route.pipe_through, :api) end) 30 | |> Enum.reduce([], fn(route, routes_docs) -> 31 | case process_route(route, requests_list) do 32 | {:ok, route_doc} -> 33 | routes_docs ++ [route_doc] 34 | _ -> 35 | routes_docs 36 | end 37 | end) 38 | end 39 | 40 | defp requests(routes, test_conns) do 41 | Enum.reduce test_conns, [], fn(conn, list) -> 42 | case find_route(routes, conn.request_path) do 43 | nil -> 44 | list 45 | route -> 46 | list ++ [request_map(route, conn)] 47 | end 48 | end 49 | end 50 | 51 | defp request_map(route, conn) do 52 | request = %{ 53 | method: conn.method, 54 | path: route.path, 55 | response: %{ 56 | status: conn.status, 57 | body: conn.resp_body 58 | } 59 | } 60 | if conn.body_params == %{} do 61 | request 62 | else 63 | request 64 | |> Map.put(:body, Poison.encode!(conn.body_params)) 65 | end 66 | end 67 | 68 | defp find_route(routes, path) do 69 | routes 70 | |> Enum.sort_by(fn(route) -> -byte_size(route.path) end) 71 | |> Enum.find(fn(route) -> 72 | route_match?(route.path, path) 73 | end) 74 | end 75 | 76 | defp route_match?(route, path) do 77 | route_regex = Regex.replace(~r/(:[^\/]+)/, route, "([^/]+)") |> Regex.compile! 78 | Regex.match?(route_regex, path) 79 | end 80 | 81 | defp process_route(route, requests) do 82 | controller = Module.concat([:Elixir | Module.split(route.plug)]) 83 | method = route.verb |> Atom.to_string |> String.upcase 84 | route_requests = Enum.filter(requests, fn(request) -> request.method == method and request.path == route.path end) 85 | try do 86 | route_docs = 87 | apply(controller, :api_doc, [method, route.path]) 88 | |> set_default_group(route) 89 | |> Map.put(:requests, route_requests) 90 | 91 | {:ok, route_docs} 92 | rescue 93 | UndefinedFunctionError -> 94 | :error 95 | FunctionClauseError -> 96 | :error 97 | end 98 | end 99 | 100 | defp set_default_group(%{group: group} = route_docs, route) when is_nil(group) do 101 | group = route.plug |> Phoenix.Naming.resource_name("Controller") |> Phoenix.Naming.humanize 102 | 103 | route_docs 104 | |> Map.put(:group, group) 105 | end 106 | 107 | defp set_default_group(route_docs, _), do: route_docs 108 | 109 | end 110 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixApiDocs.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.1.0" 5 | 6 | def project do 7 | [app: :phoenix_api_docs, 8 | version: @version, 9 | elixir: "~> 1.0", 10 | description: "PhoenixApiDocs generates API documentation from annotations in controllers actions and tests cases.", 11 | package: package(), 12 | deps: deps()] 13 | end 14 | 15 | # Configuration for the OTP application 16 | # 17 | # Type `mix help compile.app` for more information 18 | def application do 19 | [registered: [PhoenixApiDocs.ConnLogger], 20 | mod: {PhoenixApiDocs, []}, 21 | env: [ 22 | docs_path: "docs" 23 | ]] 24 | end 25 | 26 | # Should work with all versions 27 | defp deps do 28 | [ 29 | {:plug, ">= 0.0.0"}, 30 | {:poison, ">= 0.0.0"}, 31 | {:ex_doc, ">= 0.0.0", only: :dev} 32 | ] 33 | end 34 | 35 | defp package do 36 | [ 37 | files: ["lib", "mix.exs", "README.md"], 38 | contributors: ["Paul Smoczyk"], 39 | licenses: ["MIT"], 40 | links: %{"GitHub" => "https://github.com/smoku/phoenix_api_docs"} 41 | ] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "1.0.1", "2c2cd903bfdc3de3f189bd9a8d4569a075b88a8981ded9a0d95672f6e2b63141", [:mix], []}, 2 | "ex_doc": {:hex, :ex_doc, "0.13.0", "aa2f8fe4c6136a2f7cfc0a7e06805f82530e91df00e2bff4b4362002b43ada65", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, 3 | "mime": {:hex, :mime, "1.0.1", "05c393850524767d13a53627df71beeebb016205eb43bfbd92d14d24ec7a1b51", [:mix], []}, 4 | "plug": {:hex, :plug, "1.2.0", "496bef96634a49d7803ab2671482f0c5ce9ce0b7b9bc25bc0ae8e09859dd2004", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, 5 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}} 6 | --------------------------------------------------------------------------------