├── .circleci └── config.yml ├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── mix │ └── tasks │ │ ├── rolodex.ex │ │ └── rolodex.gen.docs.ex ├── rolodex.ex └── rolodex │ ├── config.ex │ ├── dsl.ex │ ├── field.ex │ ├── headers.ex │ ├── processors │ ├── open_api.ex │ └── processor.ex │ ├── request_body.ex │ ├── response.ex │ ├── route.ex │ ├── router.ex │ ├── router │ └── route_info.ex │ ├── schema.ex │ ├── utils.ex │ └── writers │ ├── file_writer.ex │ ├── mock.ex │ └── writer.ex ├── mix.exs ├── mix.lock └── test ├── rolodex ├── config_test.exs ├── field_test.exs ├── headers_test.exs ├── processors │ └── open_api_test.exs ├── request_body_test.exs ├── response_test.exs ├── route_test.exs ├── router_test.exs ├── schema_test.exs └── utils_test.exs ├── rolodex_test.exs ├── support └── mocks │ ├── controllers.ex │ ├── headers.ex │ ├── phoenix_routers.ex │ ├── request_bodies.ex │ ├── responses.ex │ ├── routers.ex │ └── schemas.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Elixir CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-elixir/ for more details 4 | version: 2 5 | jobs: 6 | build: 7 | docker: 8 | # specify the version here 9 | - image: circleci/elixir:1.7.3 10 | 11 | # Specify service dependencies here if necessary 12 | # CircleCI maintains a library of pre-built images 13 | # documented at https://circleci.com/docs/2.0/circleci-images/ 14 | # - image: circleci/postgres:9.4 15 | 16 | working_directory: ~/rolodex 17 | steps: 18 | - checkout 19 | 20 | # specify any bash command here prefixed with `run: ` 21 | - run: mix local.hex --force 22 | - run: mix local.rebar --force 23 | - run: mix deps.get 24 | - run: mix test 25 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | swag-*.tar 24 | 25 | .elixir_ls 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Frame.io 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 | # Rolodex 2 | 3 | [![hex.pm version](https://img.shields.io/hexpm/v/rolodex.svg)](https://hex.pm/packages/rolodex) [![CircleCI](https://circleci.com/gh/Frameio/rolodex.svg?style=svg)](https://circleci.com/gh/Frameio/rolodex) 4 | 5 | Rolodex generates documentation for your Phoenix API. 6 | 7 | Simply annotate your Phoenix controller action functions with `@doc` metadata, and Rolodex will turn these descriptions into valid documentation for any platform. 8 | 9 | Currently supports: 10 | - [OpenAPI 3.0](https://swagger.io/specification/) 11 | 12 | ## Disclaimer 13 | 14 | Rolodex is currently under active development! The API is a work in progress as we head towards v1.0. 15 | 16 | ## Documentation 17 | 18 | See [https://hexdocs.pm/rolodex](https://hexdocs.pm/rolodex/Rolodex.html) 19 | 20 | ## Installation 21 | 22 | Rolodex is [available in Hex](https://hex.pm/packages/rolodex). Add it to your 23 | deps in `mix.exs`: 24 | 25 | ```elixir 26 | def deps do 27 | [ 28 | {:rolodex, "~> 0.10.0"} 29 | ] 30 | end 31 | ``` 32 | -------------------------------------------------------------------------------- /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 | config :phoenix, :json_library, Jason 6 | -------------------------------------------------------------------------------- /lib/mix/tasks/rolodex.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Rolodex do 2 | use Mix.Task 3 | 4 | @shortdoc "Prints Rolodex help information" 5 | 6 | @moduledoc """ 7 | Prints all available Rolodex tasks. 8 | 9 | mix rolodex 10 | """ 11 | 12 | @doc false 13 | def run(_args) do 14 | Mix.shell().info("\nAvailable Rolodex Tasks:\n") 15 | Mix.Tasks.Help.run(["--search", "rolodex."]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/mix/tasks/rolodex.gen.docs.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Rolodex.Gen.Docs do 2 | use Mix.Task 3 | 4 | @shortdoc "Runs Rolodex to generate API docs." 5 | 6 | @doc false 7 | def run(_args) do 8 | IO.puts("Rolodex is compiling your docs...\n") 9 | 10 | Application.get_all_env(:rolodex)[:module] 11 | |> Rolodex.Config.new() 12 | |> Rolodex.run() 13 | |> log_result() 14 | end 15 | 16 | defp log_result(renders) do 17 | renders 18 | |> Enum.reduce([], fn 19 | {:ok, _}, acc -> acc 20 | {:error, err}, acc -> [err | acc] 21 | end) 22 | |> case do 23 | [] -> 24 | IO.puts("Done!") 25 | 26 | errs -> 27 | IO.puts("Rolodex failed to compile some docs with the following errors:") 28 | Enum.each(errs, &IO.inspect(&1)) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/rolodex.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex do 2 | @moduledoc """ 3 | Rolodex generates documentation for your Phoenix API. 4 | 5 | Rolodex transforms the structured `@doc` annotations on your Phoenix Controller 6 | action functions into documentation API documentation in the format of your 7 | choosing. Rolodex ships with default support for OpenAPI 3.0 (Swagger) docs. 8 | 9 | `Rolodex.run/1` encapsulates the full documentation generation process. When 10 | invoked, it will: 11 | 12 | 1. Traverse your Rolodex Router 13 | 2. Collect documentation data for the API endpoints exposed by your router 14 | 3. Serialize the data into a format of your choosing (e.g. Swagger JSON) 15 | 4. Write the serialized data out to a destination of your choosing. 16 | 17 | Rolodex can be configured in the `config/` files for your Phoenix project. See 18 | `Rolodex.Config` for more details on configuration options. 19 | 20 | ## Features and resources 21 | 22 | - **Reusable components** — Support for reusable parameter schemas, request 23 | bodies, responses, and headers. 24 | - **Structured annotations** — Standardized format for annotating your Phoenix 25 | Controller action functions with documentation info 26 | - **Generic serialization** - The `Rolodex.Processor` behaviour encapsulates 27 | the basic steps needed to serialize API metadata into documentation. Rolodex 28 | ships with a valid OpenAPI 3.0 (Swagger) JSON processor 29 | (see: `Rolodex.Processors.OpenAPI`) 30 | - **Generic writing** - The `Rolodex.Writer` behaviour encapsulates the basic 31 | steps needed to write out formatted docs. Rolodex ships with a file writer ( 32 | see: `Rolodex.Writers.FileWriter`) 33 | 34 | ## Further reading 35 | 36 | - `Rolodex.Router` — for defining which routes Rolodex should document 37 | - `Rolodex.Route` — for info on how to structure your doc annotations 38 | - `Rolodex.Schema` — for defining reusable request and response data schemas 39 | - `Rolodex.RequestBody` — for defining rusable request body parameters 40 | - `Rolodex.Response` — for defining reusable API responses 41 | - `Rolodex.Headers` — for defining reusable request and response headers 42 | - `Rolodex.Config` — for configuring your Phoenix app to use Rolodex 43 | 44 | ## High level example 45 | 46 | # Your Phoenix router 47 | defmodule MyPhoenixRouter do 48 | pipeline :api do 49 | plug MyPlug 50 | end 51 | 52 | scope "/api" do 53 | pipe_through [:api] 54 | 55 | get "/test", MyController, :index 56 | end 57 | end 58 | 59 | # Your Rolodex router, which tells Rolodex which routes to document 60 | defmodule MyRouter do 61 | use Rolodex.Router 62 | 63 | router MyPhoenixRouter do 64 | get "/api/test" 65 | end 66 | end 67 | 68 | # Your controller 69 | defmodule MyController do 70 | @doc [ 71 | auth: :BearerAuth, 72 | headers: ["X-Request-ID": uuid, required: true], 73 | query_params: [include: :string], 74 | path_params: [user_id: :uuid], 75 | body: MyRequestBody, 76 | responses: %{200 => MyResponse}, 77 | metadata: [public: true], 78 | tags: ["foo", "bar"] 79 | ] 80 | @doc "My index action" 81 | def index(conn, _), do: conn 82 | end 83 | 84 | # Your request body 85 | defmodule MyRequestBody do 86 | use Rolodex.RequestBody 87 | 88 | request_body "MyRequestBody" do 89 | desc "A request body" 90 | 91 | content "application/json" do 92 | schema do 93 | field :id, :integer 94 | field :name, :string 95 | end 96 | 97 | example :request, %{id: "123", name: "Ada Lovelace"} 98 | end 99 | end 100 | end 101 | 102 | # Some shared headers for your response 103 | defmodule RateLimitHeaders do 104 | use Rolodex.Headers 105 | 106 | headers "RateLimitHeaders" do 107 | header "X-Rate-Limited", :boolean, desc: "Have you been rate limited" 108 | header "X-Rate-Limit-Duration", :integer 109 | end 110 | end 111 | 112 | # Your response 113 | defmodule MyResponse do 114 | use Rolodex.Response 115 | 116 | response "MyResponse" do 117 | desc "A response" 118 | headers RateLimitHeaders 119 | 120 | content "application/json" do 121 | schema MySchema 122 | example :response, %{id: "123", name: "Ada Lovelace"} 123 | end 124 | end 125 | end 126 | 127 | # Your schema 128 | defmodule MySchema do 129 | use Rolodex.Schema 130 | 131 | schema "MySchema", desc: "A schema" do 132 | field :id, :uuid 133 | field :name, :string, desc: "The name" 134 | end 135 | end 136 | 137 | # Your Rolodex config 138 | defmodule MyConfig do 139 | use Rolodex.Config 140 | 141 | def spec() do 142 | [ 143 | title: "MyApp", 144 | description: "An example", 145 | version: "1.0.0" 146 | ] 147 | end 148 | 149 | def render_groups_spec() do 150 | [router: MyRouter] 151 | end 152 | 153 | def auth_spec() do 154 | [ 155 | BearerAuth: [ 156 | type: "http", 157 | scheme: "bearer" 158 | ] 159 | ] 160 | end 161 | 162 | def pipelines_spec() do 163 | [ 164 | api: [ 165 | headers: ["Include-Meta": :boolean] 166 | ] 167 | ] 168 | end 169 | end 170 | 171 | # In mix.exs 172 | config :rolodex, module: MyConfig 173 | 174 | # Then... 175 | Application.get_all_env(:rolodex)[:module] 176 | |> Rolodex.Config.new() 177 | |> Rolodex.run() 178 | 179 | # The JSON written out to file should look like 180 | %{ 181 | "openapi" => "3.0.0", 182 | "info" => %{ 183 | "title" => "MyApp", 184 | "description" => "An example", 185 | "version" => "1.0.0" 186 | }, 187 | "paths" => %{ 188 | "/api/test" => %{ 189 | "get" => %{ 190 | "security" => [%{"BearerAuth" => []}], 191 | "metadata" => %{"public" => true}, 192 | "parameters" => [ 193 | %{ 194 | "in" => "header", 195 | "name" => "X-Request-ID", 196 | "required" => true, 197 | "schema" => %{ 198 | "type" => "string", 199 | "format" => "uuid" 200 | } 201 | }, 202 | %{ 203 | "in" => "path", 204 | "name" => "user_id", 205 | "schema" => %{ 206 | "type" => "string", 207 | "format" => "uuid" 208 | } 209 | }, 210 | %{ 211 | "in" => "query", 212 | "name" => "include", 213 | "schema" => %{ 214 | "type" => "string" 215 | } 216 | } 217 | ], 218 | "responses" => %{ 219 | "200" => %{ 220 | "$ref" => "#/components/responses/MyResponse" 221 | } 222 | }, 223 | "requestBody" => %{ 224 | "$ref" => "#/components/requestBodies/MyRequestBody" 225 | }, 226 | "tags" => ["foo", "bar"] 227 | } 228 | } 229 | }, 230 | "components" => %{ 231 | "requestBodies" => %{ 232 | "MyRequestBody" => %{ 233 | "description" => "A request body", 234 | "content" => %{ 235 | "application/json" => %{ 236 | "schema" => %{ 237 | "type" => "object", 238 | "properties" => %{ 239 | "id" => %{"type" => "string", "format" => "uuid"}, 240 | "name" => %{"type" => "string", "description" => "The name"} 241 | } 242 | }, 243 | "examples" => %{ 244 | "request" => %{"id" => "123", "name" => "Ada Lovelace"} 245 | } 246 | } 247 | } 248 | } 249 | }, 250 | "responses" => %{ 251 | "MyResponse" => %{ 252 | "description" => "A response", 253 | "headers" => %{ 254 | "X-Rate-Limited" => %{ 255 | "description" => "Have you been rate limited", 256 | "schema" => %{ 257 | "type" => "string" 258 | } 259 | }, 260 | "X-Rate-Limit-Duration" => %{ 261 | "schema" => %{ 262 | "type" => "integer" 263 | } 264 | } 265 | }, 266 | "content" => %{ 267 | "application/json" => %{ 268 | "schema" => %{ 269 | "$ref" => "#/components/schemas/MySchema" 270 | }, 271 | "examples" => %{ 272 | "response" => %{"id" => "123", "name" => "Ada Lovelace"} 273 | } 274 | } 275 | } 276 | } 277 | }, 278 | "schemas" => %{ 279 | "MySchema" => %{ 280 | "type" => "object", 281 | "description" => "A schema", 282 | "properties" => %{ 283 | "id" => %{"type" => "string", "format" => "uuid"}, 284 | "name" => %{"type" => "string", "description" => "The name"} 285 | } 286 | } 287 | }, 288 | "securitySchemes" => %{ 289 | "BearerAuth" => %{ 290 | "type" => "http", 291 | "scheme" => "bearer" 292 | } 293 | } 294 | } 295 | } 296 | """ 297 | 298 | alias Rolodex.{ 299 | Config, 300 | Field, 301 | Headers, 302 | RenderGroupConfig, 303 | RequestBody, 304 | Response, 305 | Router, 306 | Schema 307 | } 308 | 309 | @route_fields_with_refs [:body, :headers, :responses] 310 | @ref_types [:headers, :request_body, :response, :schema] 311 | 312 | @doc """ 313 | Runs Rolodex and writes out documentation to the specified destination 314 | """ 315 | @spec run(Rolodex.Config.t()) :: :ok | {:error, any()} 316 | def run(%Config{render_groups: groups} = config) do 317 | Enum.map(groups, &compile_for_group(&1, config)) 318 | end 319 | 320 | defp compile_for_group(%RenderGroupConfig{router: router, processor: processor} = group, config) do 321 | routes = Router.build_routes(router, config) 322 | refs = generate_refs(routes) 323 | 324 | config 325 | |> processor.process(routes, refs) 326 | |> write(group) 327 | end 328 | 329 | defp write(processed, %RenderGroupConfig{writer: writer, writer_opts: opts}) do 330 | with {:ok, device} <- writer.init(opts), 331 | :ok <- writer.write(device, processed), 332 | :ok <- writer.close(device) do 333 | {:ok, processed} 334 | else 335 | err -> {:error, err} 336 | end 337 | end 338 | 339 | # Inspects the request and response parameter data for each `Rolodex.Route`. 340 | # From these routes, it collects a unique list of `Rolodex.RequestBody`, 341 | # `Rolodex.Response`, `Rolodex.Headers`, and `Rolodex.Schema` references. The 342 | # serialized refs will be passed along to a `Rolodex.Processor` behaviour. 343 | defp generate_refs(routes) do 344 | Enum.reduce( 345 | routes, 346 | %{schemas: %{}, responses: %{}, request_bodies: %{}, headers: %{}}, 347 | &refs_for_route/2 348 | ) 349 | end 350 | 351 | defp refs_for_route(route, all_refs) do 352 | route 353 | |> unserialized_refs_for_route(all_refs) 354 | |> Enum.reduce(all_refs, fn 355 | {:schema, ref}, %{schemas: schemas} = acc -> 356 | %{acc | schemas: Map.put(schemas, ref, Schema.to_map(ref))} 357 | 358 | {:response, ref}, %{responses: responses} = acc -> 359 | %{acc | responses: Map.put(responses, ref, Response.to_map(ref))} 360 | 361 | {:request_body, ref}, %{request_bodies: request_bodies} = acc -> 362 | %{acc | request_bodies: Map.put(request_bodies, ref, RequestBody.to_map(ref))} 363 | 364 | {:headers, ref}, %{headers: headers} = acc -> 365 | %{acc | headers: Map.put(headers, ref, Headers.to_map(ref))} 366 | end) 367 | end 368 | 369 | # Looks at the route fields where users can provide refs that it now needs to 370 | # serialize. Performs a DFS on each field to collect any unserialized refs. We 371 | # look at both the refs in the maps of data, PLUS refs nested within the 372 | # responses/schemas themselves. We recursively traverse this graph until we've 373 | # collected all unseen refs for the current context. 374 | defp unserialized_refs_for_route(route, all_refs) do 375 | serialized_refs = serialized_refs_list(all_refs) 376 | 377 | route 378 | |> Map.take(@route_fields_with_refs) 379 | |> Enum.reduce(MapSet.new(), fn {_, field}, acc -> 380 | collect_unserialized_refs(field, acc, serialized_refs) 381 | end) 382 | |> Enum.to_list() 383 | end 384 | 385 | defp collect_unserialized_refs(field, result, serialized_refs) when is_map(field) do 386 | field 387 | |> Field.get_refs() 388 | |> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs)) 389 | end 390 | 391 | # Shared schemas, responses, and request bodies can each have nested refs within, 392 | # so we recursively collect those. Headers shouldn't have nested refs. 393 | defp collect_unserialized_refs(ref, result, serialized_refs) when is_atom(ref) do 394 | case Field.get_ref_type(ref) do 395 | :schema -> 396 | ref 397 | |> Schema.get_refs() 398 | |> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs)) 399 | 400 | :response -> 401 | ref 402 | |> Response.get_refs() 403 | |> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs)) 404 | 405 | :request_body -> 406 | ref 407 | |> RequestBody.get_refs() 408 | |> Enum.reduce(result, &collect_ref(&1, &2, serialized_refs)) 409 | 410 | :headers -> 411 | result 412 | 413 | :error -> 414 | result 415 | end 416 | end 417 | 418 | defp collect_unserialized_refs(_, acc, _), do: acc 419 | 420 | # If the current schema ref is unserialized, add to the MapSet of unserialized 421 | # refs, and then continue the recursive traversal 422 | defp collect_ref(ref, result, serialized_refs) do 423 | ref_type = Field.get_ref_type(ref) 424 | 425 | cond do 426 | {ref_type, ref} in (Enum.to_list(result) ++ serialized_refs) -> 427 | result 428 | 429 | ref_type in @ref_types -> 430 | result = MapSet.put(result, {ref_type, ref}) 431 | collect_unserialized_refs(ref, result, serialized_refs) 432 | 433 | true -> 434 | result 435 | end 436 | end 437 | 438 | defp serialized_refs_list(%{ 439 | schemas: schemas, 440 | responses: responses, 441 | request_bodies: bodies, 442 | headers: headers 443 | }) do 444 | [schema: schemas, response: responses, request_body: bodies, headers: headers] 445 | |> Enum.reduce([], fn {ref_type, refs}, acc -> 446 | refs 447 | |> Map.keys() 448 | |> Enum.map(&{ref_type, &1}) 449 | |> Enum.concat(acc) 450 | end) 451 | end 452 | end 453 | -------------------------------------------------------------------------------- /lib/rolodex/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Config do 2 | @moduledoc """ 3 | A behaviour for defining Rolodex config and functions to parse config. 4 | 5 | To define your config for Rolodex, `use` Rolodex.Config in a module and 6 | override the default behaviour functions. Then, tell Rolodex the name of your 7 | config module in your project's configuration files. 8 | 9 | # Your config definition 10 | defmodule MyRolodexConfig do 11 | use Rolodex.Config 12 | 13 | def spec() do 14 | [ 15 | title: "My API", 16 | description: "My API's description", 17 | version: "1.0.0" 18 | ] 19 | end 20 | end 21 | 22 | # In `config.exs` 23 | config :rolodex, module: MyRolodexConfig 24 | 25 | ## Usage 26 | 27 | Your Rolodex config module exports three functions, which each return an empty 28 | list by default: 29 | 30 | - `spec/0` - Basic configuration for your Rolodex setup 31 | - `render_groups_spec/0` - Definitions for render targets for your API docs. A 32 | render group is combination of: a Rolodex Router, a processor, a writer, 33 | and options for the writer. You can specify more than one render group to create 34 | multiple docs outputs for your API. At least one render group specification is 35 | required. 36 | - `auth_spec/0` - Definitions for shared auth patterns to be used in routes. 37 | Auth definitions should follow the OpenAPI pattern, but keys can use snake_case 38 | and will be converted to camelCase for the OpenAPI target. 39 | - `pipelines_config/0` - Sets any shared defaults for your Phoenix Router 40 | pipelines. See `Rolodex.PipelineConfig` for details about valid options and defaults 41 | 42 | For `spec/0`, the following are valid options: 43 | 44 | - `description` (required) - Description for your documentation output 45 | - `title` (required) - Title for your documentation output 46 | - `version` (required) - Your documentation's version 47 | - `default_content_type` (default: "application/json") - Default content type 48 | used for request body and response schemas 49 | - `locale` (default: `"en"`) - Locale key to use when processing descriptions 50 | - `pipelines` (default: `%{}`) - Map of pipeline configs. Used to set default 51 | parameter values for all routes in a pipeline. See `Rolodex.PipelineConfig`. 52 | - `render_groups` (default: `Rolodex.RenderGroupConfig`) - List of render 53 | groups. 54 | - `server_urls` (default: []) - List of base url(s) for your API paths 55 | 56 | ## Full Example 57 | 58 | defmodule MyRolodexConfig do 59 | use Rolodex.Config 60 | 61 | def spec() do 62 | [ 63 | title: "My API", 64 | description: "My API's description", 65 | version: "1.0.0", 66 | default_content_type: "application/json+api", 67 | locale: "en", 68 | server_urls: ["https://myapp.io"] 69 | ] 70 | end 71 | 72 | def render_groups_spec() do 73 | [ 74 | [router: MyRouter, writer_opts: [file_name: "api-public.json"]], 75 | [router: MyRouter, writer_opts: [file_name: "api-private.json"]] 76 | ] 77 | end 78 | 79 | def auth_spec() do 80 | [ 81 | BearerAuth: [ 82 | type: "http", 83 | scheme: "bearer" 84 | ], 85 | OAuth: [ 86 | type: "oauth2", 87 | flows: [ 88 | authorization_code: [ 89 | authorization_url: "https://example.io/oauth2/authorize", 90 | token_url: "https://example.io/oauth2/token", 91 | scopes: [ 92 | "user.read", 93 | "account.read", 94 | "account.write" 95 | ] 96 | ] 97 | ] 98 | ] 99 | ] 100 | end 101 | 102 | def pipelines_spec() do 103 | [ 104 | api: [ 105 | headers: ["X-Request-ID": :uuid], 106 | query_params: [includes: :string] 107 | ] 108 | ] 109 | end 110 | end 111 | """ 112 | 113 | alias Rolodex.{PipelineConfig, RenderGroupConfig} 114 | 115 | import Rolodex.Utils, only: [to_struct: 2, to_map_deep: 1] 116 | 117 | @enforce_keys [ 118 | :description, 119 | :locale, 120 | :render_groups, 121 | :title, 122 | :version 123 | ] 124 | 125 | defstruct [ 126 | :description, 127 | :pipelines, 128 | :render_groups, 129 | :title, 130 | :version, 131 | default_content_type: "application/json", 132 | locale: "en", 133 | auth: %{}, 134 | server_urls: [] 135 | ] 136 | 137 | @type t :: %__MODULE__{ 138 | default_content_type: binary(), 139 | description: binary(), 140 | locale: binary(), 141 | pipelines: pipeline_configs() | nil, 142 | render_groups: [RenderGroupConfig.t()], 143 | auth: map(), 144 | server_urls: [binary()], 145 | title: binary(), 146 | version: binary() 147 | } 148 | 149 | @type pipeline_configs :: %{ 150 | optional(:atom) => PipelineConfig.t() 151 | } 152 | 153 | @callback spec() :: keyword() | map() 154 | @callback pipelines_spec() :: keyword() | map() 155 | @callback auth_spec() :: keyword() | map() 156 | @callback render_groups_spec() :: list() 157 | 158 | defmacro __using__(_) do 159 | quote do 160 | @behaviour Rolodex.Config 161 | 162 | def spec(), do: %{} 163 | def pipelines_spec(), do: %{} 164 | def auth_spec(), do: %{} 165 | def render_groups_spec(), do: [[]] 166 | 167 | defoverridable spec: 0, 168 | pipelines_spec: 0, 169 | auth_spec: 0, 170 | render_groups_spec: 0 171 | end 172 | end 173 | 174 | @spec new(module()) :: t() 175 | def new(module) do 176 | module.spec() 177 | |> Map.new() 178 | |> set_pipelines_config(module) 179 | |> set_auth_config(module) 180 | |> set_render_groups_config(module) 181 | |> to_struct(__MODULE__) 182 | end 183 | 184 | defp set_pipelines_config(opts, module) do 185 | pipelines = 186 | module.pipelines_spec() 187 | |> Map.new(fn {k, v} -> {k, PipelineConfig.new(v)} end) 188 | 189 | Map.put(opts, :pipelines, pipelines) 190 | end 191 | 192 | defp set_auth_config(opts, module), 193 | do: Map.put(opts, :auth, module.auth_spec() |> to_map_deep()) 194 | 195 | defp set_render_groups_config(opts, module) do 196 | groups = module.render_groups_spec() |> Enum.map(&RenderGroupConfig.new/1) 197 | Map.put(opts, :render_groups, groups) 198 | end 199 | end 200 | 201 | defmodule Rolodex.RenderGroupConfig do 202 | @moduledoc """ 203 | Configuration for a render group, a serialization target for your docs. You can 204 | specify one or more render groups via `Rolodex.Config` to render docs output(s) 205 | for your API. 206 | 207 | ## Options 208 | 209 | - `router` (required) - A `Rolodex.Router` definition 210 | - `processor` (default: `Rolodex.Processors.OpenAPI`) - Module implementing 211 | the `Rolodex.Processor` behaviour 212 | - `writer` (default: `Rolodex.Writers.FileWriter`) - Module implementing the 213 | `Rolodex.Writer` behaviour to be used to write out the docs 214 | - `writer_opts` (default: `[file_name: "api.json"]`) - Options keyword list 215 | passed into the writer behaviour. 216 | """ 217 | 218 | defstruct [ 219 | :router, 220 | processor: Rolodex.Processors.OpenAPI, 221 | writer: Rolodex.Writers.FileWriter, 222 | writer_opts: [file_name: "api.json"] 223 | ] 224 | 225 | @type t :: %__MODULE__{ 226 | router: module(), 227 | processor: module(), 228 | writer: module(), 229 | writer_opts: keyword() 230 | } 231 | 232 | @spec new(list() | map()) :: t() 233 | def new(params \\ []), do: struct(__MODULE__, params) 234 | end 235 | 236 | defmodule Rolodex.PipelineConfig do 237 | @moduledoc """ 238 | Defines shared params to be applied to every route within a Phoenix pipeline. 239 | 240 | ## Options 241 | 242 | - `body` (default: `%{}`) 243 | - `headers` (default: `%{}`) 244 | - `path_params` (default: `%{}`) 245 | - `query_params` (default: `%{}`) 246 | - `responses` (default: `%{}`) 247 | 248 | ## Example 249 | 250 | %Rolodex.PipelineConfig{ 251 | body: %{id: :uuid, name: :string} 252 | headers: %{"X-Request-Id" => :uuid}, 253 | query_params: %{account_id: :uuid}, 254 | responses: %{401 => SharedUnauthorizedResponse} 255 | } 256 | """ 257 | 258 | import Rolodex.Utils, only: [to_struct: 2, to_map_deep: 1] 259 | 260 | defstruct auth: [], 261 | body: %{}, 262 | headers: %{}, 263 | path_params: %{}, 264 | query_params: %{}, 265 | responses: %{} 266 | 267 | @type t :: %__MODULE__{ 268 | auth: list() | map(), 269 | body: map(), 270 | headers: map(), 271 | path_params: map(), 272 | query_params: map(), 273 | responses: map() 274 | } 275 | 276 | @spec new(list() | map()) :: t() 277 | def new(params \\ []) do 278 | params 279 | |> Map.new(fn {k, v} -> {k, to_map_deep(v)} end) 280 | |> to_struct(__MODULE__) 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /lib/rolodex/dsl.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.DSL do 2 | @moduledoc false 3 | 4 | alias Rolodex.{Field, Schema} 5 | 6 | # Sets the various shared module attributes used in DSL macros to collect 7 | # metadata definitions 8 | defmacro __using__(_) do 9 | quote do 10 | # Used in various macro helpers 11 | alias Rolodex.Field 12 | 13 | # Used to collect content body (requests, responses) metadata 14 | Module.register_attribute(__MODULE__, :content_types, accumulate: true) 15 | Module.register_attribute(__MODULE__, :current_content_type, accumulate: false) 16 | Module.register_attribute(__MODULE__, :body_description, accumulate: false) 17 | Module.register_attribute(__MODULE__, :headers, accumulate: true) 18 | Module.register_attribute(__MODULE__, :examples, accumulate: true) 19 | 20 | # Used to collect schema definition metadata 21 | Module.register_attribute(__MODULE__, :fields, accumulate: true) 22 | Module.register_attribute(__MODULE__, :partials, accumulate: true) 23 | 24 | # Set defaults for non-accumulators 25 | @current_content_type nil 26 | @body_description nil 27 | end 28 | end 29 | 30 | ### Macro Helpers ### 31 | 32 | # Opens up a shared content body definition (i.e. request body or response) 33 | def def_content_body(type, name, do: block) do 34 | quote do 35 | unquote(block) 36 | 37 | def unquote(type)(:name), do: unquote(name) 38 | def unquote(type)(:desc), do: @body_description 39 | def unquote(type)(:headers), do: @headers |> Enum.reverse() 40 | def unquote(type)(:content_types), do: @content_types |> Enum.reverse() 41 | end 42 | end 43 | 44 | # Sets the description of a content body 45 | def set_desc(str) do 46 | quote do 47 | @body_description unquote(str) 48 | end 49 | end 50 | 51 | # Sets the headers for a response 52 | def set_headers({:__aliases__, _, _} = mod) do 53 | quote do 54 | @headers Field.new(unquote(mod)) 55 | end 56 | end 57 | 58 | def set_headers(headers) do 59 | quote do 60 | @headers unquote(headers) |> Map.new(fn {header, opts} -> {header, Field.new(opts)} end) 61 | end 62 | end 63 | 64 | # Opens up a content body chunk for a specific content-type (e.g. "application/json") 65 | def def_content_type_shape(type, key, do: block) do 66 | quote do 67 | @content_types unquote(key) 68 | @current_content_type unquote(key) 69 | 70 | unquote(block) 71 | 72 | def unquote(type)({unquote(key), :examples}), do: @examples |> Enum.reverse() 73 | 74 | Module.delete_attribute(__MODULE__, :examples) 75 | end 76 | end 77 | 78 | # Sets an example for the current content-type 79 | def set_example(type, name, example_body) do 80 | quote do 81 | @examples unquote(name) 82 | 83 | def unquote(type)({@current_content_type, :examples, unquote(name)}), 84 | do: unquote(example_body) 85 | end 86 | end 87 | 88 | # Opens up a schema definition. This helper is used both to define shared 89 | # schema modules (Rolodex.Schema) and to define inline schemas via the macro 90 | # DSL within content bodies 91 | def set_schema(type, do: block) do 92 | quote do 93 | unquote(block) 94 | 95 | # @current_content_type will be `nil` when using this helper in Rolodex.Schema 96 | def unquote(type)({@current_content_type, :schema}) do 97 | fields = Map.new(@fields, fn {id, opts} -> {id, Field.new(opts)} end) 98 | partials = @partials |> Enum.reverse() 99 | 100 | Field.new( 101 | type: :object, 102 | properties: Rolodex.DSL.schema_fields_with_partials(fields, partials) 103 | ) 104 | end 105 | 106 | Module.delete_attribute(__MODULE__, :fields) 107 | Module.delete_attribute(__MODULE__, :partials) 108 | end 109 | end 110 | 111 | # Sets the schema for the current content-type 112 | def set_schema(type, mods) when is_list(mods) do 113 | quote do 114 | def unquote(type)({@current_content_type, :schema}) do 115 | Field.new(type: :list, of: unquote(mods)) 116 | end 117 | end 118 | end 119 | 120 | # Sets the schema for the current content-type 121 | def set_schema(type, mod) do 122 | quote do 123 | def unquote(type)({@current_content_type, :schema}) do 124 | Field.new(unquote(mod)) 125 | end 126 | end 127 | end 128 | 129 | # Sets the schema for the current content-type 130 | def set_schema(type, collection_type, of: mods) do 131 | quote do 132 | def unquote(type)({@current_content_type, :schema}) do 133 | Field.new(type: unquote(collection_type), of: unquote(mods)) 134 | end 135 | end 136 | end 137 | 138 | # Sets a field within a schema block or headers block 139 | def set_field(attr, identifier, list_items, _opts) when is_list(list_items) do 140 | quote do 141 | Module.put_attribute( 142 | __MODULE__, 143 | unquote(attr), 144 | {unquote(identifier), [type: :list, of: unquote(list_items)]} 145 | ) 146 | end 147 | end 148 | 149 | # Sets a field within a schema block or headers block 150 | def set_field(attr, identifier, type, opts) do 151 | quote do 152 | Module.put_attribute( 153 | __MODULE__, 154 | unquote(attr), 155 | {unquote(identifier), [type: unquote(type)] ++ unquote(opts)} 156 | ) 157 | end 158 | end 159 | 160 | # Sets a partial within a schema block 161 | def set_partial(mod) do 162 | quote do 163 | @partials Field.new(unquote(mod)) 164 | end 165 | end 166 | 167 | ### Function Helpers ### 168 | 169 | @doc """ 170 | Check the given module against the given module type 171 | """ 172 | @spec is_module_of_type?(module(), atom()) :: boolean() 173 | def is_module_of_type?(mod, type) when is_atom(mod) do 174 | case Code.ensure_compiled(mod) do 175 | {:module, loaded} -> function_exported?(loaded, type, 1) 176 | _ -> false 177 | end 178 | end 179 | 180 | def is_module_of_type?(_, _), do: false 181 | 182 | # @doc """ 183 | # Serializes content body metadata 184 | # """ 185 | # @spec to_content_body_map(function()) :: map() 186 | def to_content_body_map(fun) do 187 | %{ 188 | desc: fun.(:desc), 189 | headers: fun.(:headers), 190 | content: serialize_content(fun) 191 | } 192 | end 193 | 194 | defp serialize_content(fun) do 195 | fun.(:content_types) 196 | |> Map.new(fn content_type -> 197 | data = %{ 198 | schema: fun.({content_type, :schema}), 199 | examples: serialize_examples(fun, content_type) 200 | } 201 | 202 | {content_type, data} 203 | end) 204 | end 205 | 206 | defp serialize_examples(fun, content_type) do 207 | fun.({content_type, :examples}) 208 | |> Map.new(&{&1, fun.({content_type, :examples, &1})}) 209 | end 210 | 211 | # Collects nested refs in a content body 212 | def get_refs_in_content_body(fun) do 213 | fun 214 | |> to_content_body_map() 215 | |> Map.take([:headers, :content]) 216 | |> collect_refs(MapSet.new()) 217 | |> Enum.to_list() 218 | end 219 | 220 | defp collect_refs(data, refs) do 221 | refs 222 | |> set_headers_ref(data) 223 | |> set_content_refs(data) 224 | end 225 | 226 | defp set_headers_ref(refs, %{headers: []}), do: refs 227 | 228 | defp set_headers_ref(refs, %{headers: headers}), 229 | do: Enum.reduce(headers, refs, &collect_headers_refs/2) 230 | 231 | defp collect_headers_refs(%{type: :ref, ref: ref}, refs), do: MapSet.put(refs, ref) 232 | defp collect_headers_refs(_, refs), do: refs 233 | 234 | defp set_content_refs(refs, %{content: content}) do 235 | Enum.reduce(content, refs, fn {_, %{schema: schema}}, acc -> 236 | schema 237 | |> Field.get_refs() 238 | |> MapSet.new() 239 | |> MapSet.union(acc) 240 | end) 241 | end 242 | 243 | # Merges partials into schema fields 244 | def schema_fields_with_partials(fields, []), do: fields 245 | 246 | def schema_fields_with_partials(fields, partials), 247 | do: Enum.reduce(partials, fields, &merge_partial/2) 248 | 249 | defp merge_partial(%{type: :ref, ref: ref}, fields), 250 | do: ref |> Schema.to_map() |> merge_partial(fields) 251 | 252 | defp merge_partial(%{properties: props}, fields), do: Map.merge(fields, props) 253 | end 254 | -------------------------------------------------------------------------------- /lib/rolodex/field.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Field do 2 | @moduledoc """ 3 | Shared logic for parsing parameter fields. 4 | 5 | `Rolodex.RequestBody`, `Rolodex.Response`, and `Rolodex.Schema` each use this 6 | module to parse parameter metadata. `new/1` transforms a bare map into a 7 | standardized parameter definition format. `get_refs/1` takes a parameter map 8 | returned by `new/1 and traverses it, searching for any refs to a RequestBody, 9 | Response, or Schema. 10 | """ 11 | 12 | alias Rolodex.{Headers, RequestBody, Response, Schema} 13 | 14 | @type ref_type :: :headers | :request_body | :response | :schema 15 | @ref_types [:headers, :request_body, :response, :schema] 16 | 17 | @doc """ 18 | Parses parameter data into maps with a standardized shape. 19 | 20 | Every field within the map returned will have a `type`. Some fields, like lists 21 | and objects, have other data nested within. Other fields hold references (called 22 | `refs`) to `Rolodex.RequestBody`, `Rolodex.Response` or `Rolodex.Schema` modules. 23 | 24 | You can think of the output as an AST of parameter data that a `Rolodex.Processor` 25 | behaviour can serialize into documentation output. 26 | 27 | ## Examples 28 | 29 | ### Parsing primitive data types (e.g. `integer`) 30 | 31 | Valid options for a primitive are: 32 | 33 | - `enum` - a list of possible values 34 | - `desc` 35 | - `default` 36 | - `format` 37 | - `maximum` 38 | - `minimum` 39 | - `required` 40 | 41 | # Creating a simple field with a primitive type 42 | iex> Rolodex.Field.new(:integer) 43 | %{type: :integer} 44 | 45 | # With additional options 46 | iex> Rolodex.Field.new(type: :integer, desc: "My count", enum: [1, 2]) 47 | %{type: :integer, desc: "My count", enum: [1, 2]} 48 | 49 | ### OpenAPI string formats 50 | 51 | When serializing docs for OpenAPI (i.e. Swagger), the following primitive field 52 | types will be converted into string formats: 53 | 54 | - `date` 55 | - `datetime` 56 | - `date-time` 57 | - `password` 58 | - `byte` 59 | - `binary` 60 | - `uuid` 61 | - `email` 62 | - `uri` 63 | 64 | For example: 65 | 66 | # The following field 67 | iex> Rolodex.Field.new(:date) 68 | %{type: :date} 69 | 70 | # Will be serialized like the following for OpenAPI docs 71 | %{type: :string, format: :date} 72 | 73 | ### Parsing collections: objects and lists 74 | 75 | # Create an object 76 | iex> Rolodex.Field.new(type: :object, properties: %{id: :uuid, name: :string}) 77 | %{ 78 | type: :object, 79 | properties: %{ 80 | id: %{type: :uuid}, 81 | name: %{type: :string} 82 | } 83 | } 84 | 85 | # Shorthand for creating an object: a top-level map or keyword list 86 | iex> Rolodex.Field.new(%{id: :uuid, name: :string}) 87 | %{ 88 | type: :object, 89 | properties: %{ 90 | id: %{type: :uuid}, 91 | name: %{type: :string} 92 | } 93 | } 94 | 95 | # Create a list 96 | iex> Rolodex.Field.new(type: :list, of: [:string, :uuid]) 97 | %{ 98 | type: :list, 99 | of: [ 100 | %{type: :string}, 101 | %{type: :uuid} 102 | ] 103 | } 104 | 105 | # Shorthand for creating a list: a list of types 106 | iex> Rolodex.Field.new([:string, :uuid]) 107 | %{ 108 | type: :list, 109 | of: [ 110 | %{type: :string}, 111 | %{type: :uuid} 112 | ] 113 | } 114 | 115 | ### Arbitrary collections 116 | 117 | Use the `one_of` type to describe a field that can be one of the provided types 118 | 119 | iex> Rolodex.Field.new(type: :one_of, of: [:string, :uuid]) 120 | %{ 121 | type: :one_of, 122 | of: [ 123 | %{type: :string}, 124 | %{type: :uuid} 125 | ] 126 | } 127 | 128 | ### Working with refs 129 | 130 | iex> defmodule DemoSchema do 131 | ...> use Rolodex.Schema 132 | ...> 133 | ...> schema "DemoSchema" do 134 | ...> field :id, :uuid 135 | ...> end 136 | ...> end 137 | iex> 138 | iex> # Creating a field with a `Rolodex.Schema` as the top-level type 139 | iex> Rolodex.Field.new(DemoSchema) 140 | %{type: :ref, ref: Rolodex.FieldTest.DemoSchema} 141 | iex> 142 | iex> # Creating a collection field with various members, including a nested schema 143 | iex> Rolodex.Field.new(type: :list, of: [:string, DemoSchema]) 144 | %{ 145 | type: :list, 146 | of: [ 147 | %{type: :string}, 148 | %{type: :ref, ref: Rolodex.FieldTest.DemoSchema} 149 | ] 150 | } 151 | """ 152 | @spec new(atom() | module() | list() | map()) :: map() 153 | def new(opts) 154 | 155 | def new(type) when is_atom(type), do: new(type: type) 156 | 157 | def new(opts) when is_list(opts) do 158 | case Keyword.keyword?(opts) do 159 | true -> 160 | opts 161 | |> Map.new() 162 | |> new() 163 | 164 | # List shorthand: if a plain list is provided, turn it into a `type: :list` field 165 | false -> 166 | new(%{type: :list, of: opts}) 167 | end 168 | end 169 | 170 | def new(opts) when is_map(opts) and map_size(opts) == 0, do: %{} 171 | 172 | def new(opts) when is_map(opts), do: create_field(opts) 173 | 174 | defp create_field(%{type: :object, properties: props} = metadata) do 175 | resolved_props = Map.new(props, fn {k, v} -> {k, new(v)} end) 176 | %{metadata | properties: resolved_props} 177 | end 178 | 179 | defp create_field(%{type: :list, of: items} = metadata) do 180 | resolved_items = Enum.map(items, &new/1) 181 | %{metadata | of: resolved_items} 182 | end 183 | 184 | defp create_field(%{type: :one_of, of: items} = metadata) do 185 | resolved_items = Enum.map(items, &new/1) 186 | %{metadata | of: resolved_items} 187 | end 188 | 189 | defp create_field(%{type: type} = metadata) do 190 | cond do 191 | get_ref_type(type) in @ref_types -> %{type: :ref, ref: type} 192 | true -> metadata 193 | end 194 | end 195 | 196 | # Object shorthand: if a map is provided without a reserved `type: ` 197 | # identifier, turn it into a `type: :object` field 198 | defp create_field(data) when is_map(data) do 199 | new(%{type: :object, properties: data}) 200 | end 201 | 202 | @doc """ 203 | Traverses a formatted map returned by `new/1` and returns a unique list of all 204 | refs to `Rolodex.Response` and `Rolodex.Schema` modules within. 205 | 206 | ## Examples 207 | 208 | iex> defmodule NestedSchema do 209 | ...> use Rolodex.Schema 210 | ...> 211 | ...> schema "NestedSchema" do 212 | ...> field :id, :uuid 213 | ...> end 214 | ...> end 215 | iex> 216 | iex> defmodule TopSchema do 217 | ...> use Rolodex.Schema 218 | ...> 219 | ...> schema "TopSchema", desc: "An example" do 220 | ...> # Atomic field with no description 221 | ...> field :id, :uuid 222 | ...> 223 | ...> # Atomic field with a description 224 | ...> field :name, :string, desc: "The schema's name" 225 | ...> 226 | ...> # A field that refers to another, nested object 227 | ...> field :other, NestedSchema 228 | ...> 229 | ...> # A field that is an array of items of one-or-more types 230 | ...> field :multi, :list, of: [:string, NestedSchema] 231 | ...> 232 | ...> # A field that is one of the possible provided types 233 | ...> field :any, :one_of, of: [:string, NestedSchema] 234 | ...> end 235 | ...> end 236 | iex> 237 | iex> # Searching for refs in a formatted map 238 | iex> Rolodex.Field.new(type: :list, of: [TopSchema, NestedSchema]) 239 | ...> |> Rolodex.Field.get_refs() 240 | [Rolodex.FieldTest.NestedSchema, Rolodex.FieldTest.TopSchema] 241 | """ 242 | @spec get_refs(module() | map()) :: [module()] 243 | def get_refs(field) 244 | 245 | def get_refs(%{of: items}) when is_list(items) do 246 | items 247 | |> Enum.reduce(MapSet.new(), &collect_refs_for_item/2) 248 | |> Enum.to_list() 249 | end 250 | 251 | def get_refs(%{type: :object, properties: props}) when is_map(props) do 252 | props 253 | |> Enum.reduce(MapSet.new(), fn {_, item}, refs -> collect_refs_for_item(item, refs) end) 254 | |> Enum.to_list() 255 | end 256 | 257 | def get_refs(%{type: :ref, ref: object}) when is_atom(object) do 258 | [object] 259 | end 260 | 261 | def get_refs(field) when is_map(field) do 262 | field 263 | |> Enum.reduce(MapSet.new(), fn {_, value}, refs -> collect_refs_for_item(value, refs) end) 264 | |> Enum.to_list() 265 | end 266 | 267 | def get_refs(_), do: [] 268 | 269 | defp collect_refs_for_item(item, refs) do 270 | case get_refs(item) do 271 | [] -> 272 | refs 273 | 274 | objects -> 275 | objects 276 | |> MapSet.new() 277 | |> MapSet.union(refs) 278 | end 279 | end 280 | 281 | @doc """ 282 | Takes a module and determines if it is a known shared module ref type: Headers, 283 | RequestBody, Response, or Schema. 284 | """ 285 | @spec get_ref_type(module()) :: ref_type() | :error 286 | def get_ref_type(mod) do 287 | cond do 288 | RequestBody.is_request_body_module?(mod) -> :request_body 289 | Response.is_response_module?(mod) -> :response 290 | Schema.is_schema_module?(mod) -> :schema 291 | Headers.is_headers_module?(mod) -> :headers 292 | true -> :error 293 | end 294 | end 295 | end 296 | -------------------------------------------------------------------------------- /lib/rolodex/headers.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Headers do 2 | @moduledoc """ 3 | Exposes functions and macros for defining reusable headers in route doc 4 | annotations or responses. 5 | 6 | It exposes the following macros, which when used together will set up the headers: 7 | 8 | - `headers/2` - for declaring the headers 9 | - `header/3` - for declaring a single header for the set 10 | 11 | It also exposes the following functions: 12 | 13 | - `is_headers_module?/1` - determines if the provided item is a module that has 14 | defined a reusable headers set 15 | - `to_map/1` - serializes the headers module into a map 16 | """ 17 | 18 | alias Rolodex.{DSL, Field} 19 | 20 | defmacro __using__(_) do 21 | quote do 22 | use Rolodex.DSL 23 | import Rolodex.Headers, only: :macros 24 | end 25 | end 26 | 27 | @doc """ 28 | Opens up the headers definition for the current module. Will name the headers 29 | set and generate a list of header fields based on the macro calls within. 30 | 31 | **Accepts** 32 | - `name` - the headers name 33 | - `block` - headers shape definition 34 | 35 | ## Example 36 | 37 | defmodule SimpleHeaders do 38 | use Rolodex.Headers 39 | 40 | headers "SimpleHeaders" do 41 | field "X-Rate-Limited", :boolean 42 | field "X-Per-Page", :integer, desc: "Number of items in the response" 43 | end 44 | end 45 | """ 46 | defmacro headers(name, do: block) do 47 | quote do 48 | unquote(block) 49 | 50 | def __headers__(:name), do: unquote(name) 51 | def __headers__(:headers), do: Map.new(@headers, fn {id, opts} -> {id, Field.new(opts)} end) 52 | end 53 | end 54 | 55 | @doc """ 56 | Sets a header field. 57 | 58 | **Accepts** 59 | 60 | - `identifier` - the header name 61 | - `type` - the header field type 62 | - `opts` (optional) - additional metadata. See `Field.new/1` for a list of 63 | valid options. 64 | """ 65 | defmacro field(identifier, type, opts \\ []) do 66 | DSL.set_field(:headers, identifier, type, opts) 67 | end 68 | 69 | @doc """ 70 | Determines if an arbitrary item is a module that has defined a reusable headers 71 | set via `Rolodex.Headers` macros 72 | 73 | ## Example 74 | 75 | defmodule SimpleHeaders do 76 | ...> use Rolodex.Headers 77 | ...> headers "SimpleHeaders" do 78 | ...> field "X-Rate-Limited", :boolean 79 | ...> end 80 | ...> end 81 | iex> 82 | # Validating a headers module 83 | Rolodex.Headers.is_headers_module?(SimpleHeaders) 84 | true 85 | iex> # Validating some other module 86 | iex> Rolodex.Headers.is_headers_module?(OtherModule) 87 | false 88 | """ 89 | @spec is_headers_module?(any()) :: boolean() 90 | def is_headers_module?(mod), do: DSL.is_module_of_type?(mod, :__headers__) 91 | 92 | @doc """ 93 | Serializes the `Rolodex.Headers` metadata into a formatted map 94 | 95 | ## Example 96 | 97 | iex> defmodule SimpleHeaders do 98 | ...> use Rolodex.Headers 99 | ...> 100 | ...> headers "SimpleHeaders" do 101 | ...> field "X-Rate-Limited", :boolean 102 | ...> field "X-Per-Page", :integer, desc: "Number of items in the response" 103 | ...> end 104 | ...> end 105 | iex> 106 | iex> Rolodex.Headers.to_map(SimpleHeaders) 107 | %{ 108 | "X-Per-Page" => %{desc: "Number of items in the response", type: :integer}, 109 | "X-Rate-Limited" => %{type: :boolean} 110 | } 111 | """ 112 | @spec to_map(module()) :: map() 113 | def to_map(mod), do: mod.__headers__(:headers) 114 | end 115 | -------------------------------------------------------------------------------- /lib/rolodex/processors/open_api.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Processors.OpenAPI do 2 | @behaviour Rolodex.Processor 3 | @open_api_version "3.0.0" 4 | 5 | @schema_metadata_keys ~w( 6 | default 7 | enum 8 | format 9 | maximum 10 | minimum 11 | type 12 | )a 13 | 14 | @valid_string_formats ~w( 15 | date 16 | date-time 17 | password 18 | byte 19 | binary 20 | uuid 21 | email 22 | uri 23 | )a 24 | 25 | alias Rolodex.{Config, Field, Headers, Route} 26 | 27 | import Rolodex.Utils, only: [camelize_map: 1] 28 | 29 | @impl Rolodex.Processor 30 | def process(config, routes, serialized_refs) do 31 | config 32 | |> process_headers() 33 | |> Map.put(:paths, process_routes(routes, config)) 34 | |> Map.put(:components, process_refs(serialized_refs, config)) 35 | |> Jason.encode!(pretty: true) 36 | end 37 | 38 | @impl Rolodex.Processor 39 | def process_headers(config) do 40 | %{ 41 | openapi: @open_api_version, 42 | servers: process_server_urls(config), 43 | info: %{ 44 | title: config.title, 45 | description: config.description, 46 | version: config.version 47 | } 48 | } 49 | end 50 | 51 | defp process_server_urls(%Config{server_urls: urls}) do 52 | for url <- urls, do: %{url: url} 53 | end 54 | 55 | @impl Rolodex.Processor 56 | def process_routes(routes, config) do 57 | routes 58 | |> Enum.group_by(&path_with_params/1) 59 | |> Map.new(fn {path, routes} -> 60 | {path, process_routes_for_path(routes, config)} 61 | end) 62 | end 63 | 64 | # Transform Phoenix-style path params to Swagger-style: /foo/:bar -> /foo/{bar} 65 | defp path_with_params(%Route{path: path}) do 66 | ~r/\/:([^\/]+)/ 67 | |> Regex.replace(path, fn _, path_param -> "/{#{path_param}}" end) 68 | end 69 | 70 | defp process_routes_for_path(routes, config) do 71 | Map.new(routes, fn %Route{verb: verb} = route -> 72 | {verb, process_route(route, config)} 73 | end) 74 | end 75 | 76 | defp process_route(route, config) do 77 | result = %{ 78 | # Swagger prefers `summary` for short, one-line descriptions of a route, 79 | # whereas `description` is meant for multi-line markdown explainers. 80 | # 81 | # TODO(bceskavich): we could support both? 82 | operationId: route.id, 83 | summary: route.desc, 84 | tags: route.tags, 85 | parameters: process_params(route), 86 | security: process_auth(route), 87 | responses: process_responses(route, config) 88 | } 89 | 90 | case process_body(route, config) do 91 | body when map_size(body) == 0 -> result 92 | body -> Map.put(result, :requestBody, body) 93 | end 94 | end 95 | 96 | defp process_params(%Route{headers: headers, path_params: path, query_params: query}) do 97 | [header: headers, path: path, query: query] 98 | |> Enum.flat_map(fn {location, params} -> 99 | Enum.map(params, &process_param(&1, location)) 100 | end) 101 | end 102 | 103 | defp process_param({name, param}, location) do 104 | %{ 105 | in: location, 106 | name: name, 107 | schema: process_schema_field(param) 108 | } 109 | |> set_param_required(param) 110 | |> set_param_description() 111 | end 112 | 113 | defp set_param_required(param, %{required: true}), do: Map.put(param, :required, true) 114 | defp set_param_required(param, _), do: param 115 | 116 | defp process_auth(%Route{auth: auth}), do: Enum.map(auth, &Map.new([&1])) 117 | 118 | defp process_body(%Route{body: body}, _) when map_size(body) == 0, do: body 119 | defp process_body(%Route{body: %{type: :ref} = body}, _), do: process_schema_field(body) 120 | 121 | defp process_body(%Route{body: body}, %Config{default_content_type: content_type}) do 122 | %{ 123 | content: %{ 124 | content_type => %{schema: process_schema_field(body)} 125 | } 126 | } 127 | end 128 | 129 | defp process_responses(%Route{responses: responses}, _) when map_size(responses) == 0, 130 | do: responses 131 | 132 | defp process_responses(%Route{responses: responses}, %Config{default_content_type: content_type}) do 133 | responses 134 | |> Map.new(fn 135 | {status_code, :ok} -> 136 | {status_code, %{description: "OK"}} 137 | 138 | {status_code, %{type: :ref} = response} -> 139 | {status_code, process_schema_field(response)} 140 | 141 | {status_code, response} -> 142 | resp = %{ 143 | content: %{ 144 | content_type => %{schema: process_schema_field(response)} 145 | } 146 | } 147 | 148 | {status_code, resp} 149 | end) 150 | end 151 | 152 | @impl Rolodex.Processor 153 | def process_refs( 154 | %{ 155 | request_bodies: request_bodies, 156 | responses: responses, 157 | schemas: schemas 158 | }, 159 | %Config{auth: auth} 160 | ) do 161 | %{ 162 | requestBodies: process_content_body_refs(request_bodies, :__request_body__), 163 | responses: process_content_body_refs(responses, :__response__), 164 | schemas: process_schema_refs(schemas), 165 | securitySchemes: camelize_map(auth) 166 | } 167 | end 168 | 169 | defp process_content_body_refs(refs, ref_type) do 170 | Map.new(refs, fn {mod, ref} -> 171 | name = apply(mod, ref_type, [:name]) 172 | content = process_content_body_ref(ref) 173 | {name, content} 174 | end) 175 | end 176 | 177 | defp process_content_body_ref(%{desc: desc, content: content} = rest) do 178 | %{ 179 | description: desc, 180 | content: 181 | content 182 | |> Map.new(fn {content_type, content_val} -> 183 | {content_type, process_content_body_ref_data(content_val)} 184 | end) 185 | } 186 | |> process_content_body_headers(rest) 187 | end 188 | 189 | defp process_content_body_ref_data(%{schema: schema, examples: examples}) 190 | when map_size(examples) > 0 do 191 | %{ 192 | schema: process_schema_field(schema), 193 | examples: process_content_body_examples(examples) 194 | } 195 | end 196 | 197 | defp process_content_body_ref_data(%{schema: schema}) do 198 | %{schema: process_schema_field(schema)} 199 | end 200 | 201 | defp process_content_body_examples(examples), 202 | do: Map.new(examples, fn {name, example} -> {name, %{value: example}} end) 203 | 204 | defp process_content_body_headers(content, %{headers: []}), do: content 205 | 206 | defp process_content_body_headers(content, %{headers: headers}), 207 | do: Map.put(content, :headers, Enum.reduce(headers, %{}, &serialize_headers_group/2)) 208 | 209 | # OpenAPI 3 does not support using `$ref` syntax for reusable header components, 210 | # so we need to serialize them out in full each time. 211 | defp serialize_headers_group(%{type: :ref, ref: ref}, serialized) do 212 | headers = 213 | ref 214 | |> Headers.to_map() 215 | |> process_header_fields() 216 | 217 | Map.merge(serialized, headers) 218 | end 219 | 220 | defp serialize_headers_group(headers, serialized), 221 | do: Map.merge(serialized, process_header_fields(headers)) 222 | 223 | defp process_header_fields(fields) do 224 | Map.new(fields, fn {header, value} -> {header, process_header_field(value)} end) 225 | end 226 | 227 | defp process_header_field(value) do 228 | %{schema: process_schema_field(value)} 229 | |> set_param_description() 230 | end 231 | 232 | defp process_schema_refs(schemas) do 233 | Map.new(schemas, fn {mod, schema} -> 234 | {mod.__schema__(:name), process_schema_field(schema)} 235 | end) 236 | end 237 | 238 | defp process_schema_field(%{type: :ref, ref: ref}) when ref != nil do 239 | %{"$ref" => ref_path(ref)} 240 | end 241 | 242 | defp process_schema_field(%{type: :object, properties: props} = object_field) do 243 | object = %{ 244 | type: :object, 245 | properties: props |> Map.new(fn {k, v} -> {k, process_schema_field(v)} end) 246 | } 247 | 248 | props 249 | |> collect_required_object_props() 250 | |> set_required_object_props(object) 251 | |> put_description(object_field) 252 | end 253 | 254 | defp process_schema_field(%{type: :list, of: items} = list_field) when length(items) == 1 do 255 | %{ 256 | type: :array, 257 | items: items |> Enum.at(0) |> process_schema_field() 258 | } 259 | |> put_description(list_field) 260 | end 261 | 262 | defp process_schema_field(%{type: :list, of: items} = list_field) do 263 | %{ 264 | type: :array, 265 | items: %{ 266 | oneOf: items |> Enum.map(&process_schema_field/1) 267 | } 268 | } 269 | |> put_description(list_field) 270 | end 271 | 272 | defp process_schema_field(%{type: :one_of, of: items}) do 273 | %{ 274 | oneOf: items |> Enum.map(&process_schema_field/1) 275 | } 276 | end 277 | 278 | defp process_schema_field(%{type: type} = field) when type in @valid_string_formats do 279 | field 280 | |> set_formatted_string_field(type) 281 | |> process_schema_field() 282 | end 283 | 284 | # Also support datetime as a single word b/c the dash is weird especially in an atom 285 | defp process_schema_field(%{type: :datetime} = field) do 286 | field 287 | |> set_formatted_string_field(:"date-time") 288 | |> process_schema_field() 289 | end 290 | 291 | defp process_schema_field(field) do 292 | field 293 | |> Map.take(@schema_metadata_keys) 294 | |> put_description(field) 295 | end 296 | 297 | ## Helpers ## 298 | 299 | defp collect_required_object_props(props), do: Enum.reduce(props, [], &do_props_collect/2) 300 | 301 | defp do_props_collect({k, %{required: true}}, acc), do: [k | acc] 302 | defp do_props_collect(_, acc), do: acc 303 | 304 | defp set_required_object_props([], object), do: object 305 | defp set_required_object_props(required, object), do: Map.put(object, :required, required) 306 | 307 | defp put_description(field, %{desc: desc}) when is_binary(desc) and desc != "" do 308 | Map.put(field, :description, desc) 309 | end 310 | 311 | defp put_description(field, _), do: field 312 | 313 | # When serializing parameters, descriptions should be placed in the top-level 314 | # parameters map, not the nested schema definition 315 | defp set_param_description(%{schema: %{description: description} = schema} = param) do 316 | param 317 | |> Map.put(:description, description) 318 | |> Map.put(:schema, Map.delete(schema, :description)) 319 | end 320 | 321 | defp set_param_description(param), do: param 322 | 323 | defp set_formatted_string_field(field, format) do 324 | field 325 | |> Map.put(:type, :string) 326 | |> Map.put(:format, format) 327 | end 328 | 329 | defp ref_path(mod) do 330 | case Field.get_ref_type(mod) do 331 | :request_body -> "#/components/requestBodies/#{mod.__request_body__(:name)}" 332 | :response -> "#/components/responses/#{mod.__response__(:name)}" 333 | :schema -> "#/components/schemas/#{mod.__schema__(:name)}" 334 | end 335 | end 336 | end 337 | -------------------------------------------------------------------------------- /lib/rolodex/processors/processor.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Processor do 2 | @moduledoc """ 3 | Takes a `Rolodex.Config.t()`, a list of `Rolodex.Route.t()`, and a map of shared 4 | `Rolodex.Schema` modules. Transforms them into a `String.t()`, formatted for 5 | the destination (e.g. Swagger JSON). 6 | 7 | The only required function is `process/3`, which is responsible for coordinating 8 | processing and returning the formatted string. 9 | """ 10 | 11 | @optional_callbacks process_headers: 1, process_routes: 2, process_refs: 2 12 | 13 | @doc """ 14 | Process is responsible for turning each `Rolodex.Route.t()` it receives and 15 | turning it into a string so that it can be written. 16 | """ 17 | @callback process(Rolodex.Config.t(), [Rolodex.Route.t()], serialized_refs :: map()) :: 18 | String.t() 19 | 20 | @doc """ 21 | Generates top-level metadata for the output. 22 | """ 23 | @callback process_headers(Rolodex.Config.t()) :: map() 24 | def process_headers(_), do: %{} 25 | 26 | @doc """ 27 | Transforms the routes. 28 | """ 29 | @callback process_routes([Rolodex.Route.t()], Rolodex.Config.t()) :: map() 30 | def process_routes(_, _), do: %{} 31 | 32 | @doc """ 33 | Transforms the shared request body, response, and schema refs 34 | """ 35 | @callback process_refs(refs :: map(), config :: Rolodex.Config.t()) :: map() 36 | def process_refs(_, _), do: %{} 37 | end 38 | -------------------------------------------------------------------------------- /lib/rolodex/request_body.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.RequestBody do 2 | @moduledoc """ 3 | Exposes functions and macros for defining reusable request bodies. 4 | 5 | It exposes the following macros, which when used together will setup a request body: 6 | 7 | - `request_body/2` - for declaring a request body 8 | - `desc/1` - for setting an (optional) request body description 9 | - `content/2` - for defining a request body shape for a specific content type 10 | - `schema/1` and `schema/2` - for defining the shape for a content type 11 | - `example/2` - for defining an (optional) request body example for a content type 12 | 13 | It also exposes the following functions: 14 | 15 | - `is_request_body_module?/1` - determines if the provided item is a module that 16 | has defined a reusable request body 17 | - `to_map/1` - serializes a request body module into a map 18 | - `get_refs/1` - traverses a request body and searches for any nested 19 | `Rolodex.Schema` refs within 20 | """ 21 | 22 | alias Rolodex.DSL 23 | 24 | defmacro __using__(_opts) do 25 | quote do 26 | use Rolodex.DSL 27 | import Rolodex.RequestBody, only: :macros 28 | end 29 | end 30 | 31 | @doc """ 32 | Opens up the request body definition for the current module. Will name the 33 | request body and generate metadata for the request body based on macro calls 34 | within the provided block. 35 | 36 | **Accept** 37 | - `name` - the request body name 38 | - `block` - request body shape definitions 39 | 40 | ## Example 41 | 42 | defmodule MyRequestBody do 43 | use Rolodex.RequestBody 44 | 45 | request_body "MyRequestBody" do 46 | desc "A demo request body with multiple content types" 47 | 48 | content "application/json" do 49 | schema MyRequestBodySchema 50 | 51 | example :request_body, %{foo: "bar"} 52 | example :other_request_body, %{bar: "baz"} 53 | end 54 | 55 | content "foo/bar" do 56 | schema AnotherRequestBodySchema 57 | example :request_body, %{foo: "bar"} 58 | end 59 | end 60 | end 61 | """ 62 | defmacro request_body(name, opts) do 63 | DSL.def_content_body(:__request_body__, name, opts) 64 | end 65 | 66 | @doc """ 67 | Sets a description for the request body 68 | """ 69 | defmacro desc(str), do: DSL.set_desc(str) 70 | 71 | @doc """ 72 | Defines a request body shape for the given content type key 73 | 74 | **Accepts** 75 | - `key` - a valid content-type key 76 | - `block` - metadata about the request body shape for this content type 77 | """ 78 | defmacro content(key, opts) do 79 | DSL.def_content_type_shape(:__request_body__, key, opts) 80 | end 81 | 82 | @doc """ 83 | Sets an example for the content type. This macro can be used multiple times 84 | within a content type block to allow multiple examples. 85 | 86 | **Accepts** 87 | - `name` - a name for the example 88 | - `body` - a map, which is the example data 89 | """ 90 | defmacro example(name, example_body) do 91 | DSL.set_example(:__request_body__, name, example_body) 92 | end 93 | 94 | @doc """ 95 | Sets a schema for the current request body content type. There are three ways 96 | you can define a schema for a content-type chunk: 97 | 98 | 1. You can pass in an alias for a reusable schema defined via `Rolodex.Schema` 99 | 2. You can define a schema inline via the same macro syntax used in `Rolodex.Schema` 100 | 3. You can define a schema inline via a bare map, which will be parsed with `Rolodex.Field` 101 | 102 | ## Examples 103 | 104 | # Via a reusable schema alias 105 | content "application/json" do 106 | schema MySchema 107 | end 108 | 109 | # Can define a schema inline via the schema + field + partial macros 110 | content "application/json" do 111 | schema do 112 | field :id, :uuid 113 | field :name, :string, desc: "The name" 114 | 115 | partial PaginationParams 116 | end 117 | end 118 | 119 | # Can provide a bare map, which will be parsed via `Rolodex.Field` 120 | content "application/json" do 121 | schema %{ 122 | type: :object, 123 | properties: %{ 124 | id: :uuid, 125 | name: :string 126 | } 127 | } 128 | end 129 | """ 130 | defmacro schema(mod), do: DSL.set_schema(:__request_body__, mod) 131 | 132 | @doc """ 133 | Sets a schema of a collection type. 134 | 135 | ## Examples 136 | 137 | # Request body is a list 138 | content "application/json" do 139 | schema :list, of: [MySchema] 140 | end 141 | 142 | # Request body is one of the provided types 143 | content "application/json" do 144 | schema :one_of, of: [MySchema, MyOtherSchema] 145 | end 146 | """ 147 | defmacro schema(collection_type, opts) do 148 | DSL.set_schema(:__request_body__, collection_type, opts) 149 | end 150 | 151 | @doc """ 152 | Adds a new field to the schema when defining a schema inline via macros. See 153 | `Rolodex.Field` for more information about valid field metadata. 154 | 155 | Accepts 156 | - `identifier` - field name 157 | - `type` - either an atom or another Rolodex.Schema module 158 | - `opts` - a keyword list of options, looks for `desc` and `of` (for array types) 159 | 160 | ## Example 161 | 162 | defmodule MyRequestBody do 163 | use Rolodex.RequestBody 164 | 165 | request_body "MyRequestBody" do 166 | content "application/json" do 167 | schema do 168 | # Atomic field with no description 169 | field :id, :uuid 170 | 171 | # Atomic field with a description 172 | field :name, :string, desc: "The object's name" 173 | 174 | # A field that refers to another, nested object 175 | field :other, OtherSchema 176 | 177 | # A field that is an array of items of one-or-more types 178 | field :multi, :list, of: [:string, OtherSchema] 179 | 180 | # You can use a shorthand to define a list field, the below is identical 181 | # to the above 182 | field :multi, [:string, OtherSchema] 183 | 184 | # A field that is one of the possible provided types 185 | field :any, :one_of, of: [:string, OtherSchema] 186 | end 187 | end 188 | end 189 | end 190 | """ 191 | defmacro field(identifier, type, opts \\ []) do 192 | DSL.set_field(:fields, identifier, type, opts) 193 | end 194 | 195 | @doc """ 196 | Adds a new partial to the schema when defining a schema inline via macros. A 197 | partial is another schema that will be serialized and merged into the top-level 198 | properties map for the current schema. Partials are useful for shared parameters 199 | used across multiple schemas. Bare keyword lists and maps that are parseable 200 | by `Rolodex.Field` are also supported. 201 | 202 | ## Example 203 | 204 | defmodule PaginationParams do 205 | use Rolodex.Schema 206 | 207 | schema "PaginationParams" do 208 | field :page, :integer 209 | field :page_size, :integer 210 | field :total_pages, :integer 211 | end 212 | end 213 | 214 | defmodule MyRequestBody do 215 | use Rolodex.RequestBody 216 | 217 | request_body "MyRequestBody" do 218 | content "application/json" do 219 | schema do 220 | field :id, :uuid 221 | partial PaginationParams 222 | end 223 | end 224 | end 225 | end 226 | """ 227 | defmacro partial(mod), do: DSL.set_partial(mod) 228 | 229 | @doc """ 230 | Determines if an arbitrary item is a module that has defined a reusable 231 | request body via `Rolodex.RequestBody` macros. 232 | 233 | ## Example 234 | 235 | iex> defmodule SimpleRequestBody do 236 | ...> use Rolodex.RequestBody 237 | ...> 238 | ...> request_body "SimpleRequestBody" do 239 | ...> content "application/json" do 240 | ...> schema MySchema 241 | ...> end 242 | ...> end 243 | ...> end 244 | iex> 245 | iex> # Validating a request body module 246 | iex> Rolodex.RequestBody.is_request_body_module?(SimpleRequestBody) 247 | true 248 | iex> # Validating some other module 249 | iex> Rolodex.RequestBody.is_request_body_module?(OtherModule) 250 | false 251 | """ 252 | @spec is_request_body_module?(any()) :: boolean() 253 | def is_request_body_module?(mod), do: DSL.is_module_of_type?(mod, :__request_body__) 254 | 255 | @doc """ 256 | Serializes the `Rolodex.RequestBody` metadata into a formatted map. 257 | 258 | ## Example 259 | 260 | iex> defmodule MySimpleSchema do 261 | ...> use Rolodex.Schema 262 | ...> 263 | ...> schema "MySimpleSchema" do 264 | ...> field :id, :uuid 265 | ...> end 266 | ...> end 267 | iex> 268 | iex> defmodule MyRequestBody do 269 | ...> use Rolodex.RequestBody 270 | ...> 271 | ...> request_body "MyRequestBody" do 272 | ...> desc "A demo request body" 273 | ...> 274 | ...> content "application/json" do 275 | ...> schema MySimpleSchema 276 | ...> example :request_body, %{id: "123"} 277 | ...> end 278 | ...> 279 | ...> content "application/json-list" do 280 | ...> schema [MySimpleSchema] 281 | ...> example :request_body, [%{id: "123"}] 282 | ...> example :another_request_body, [%{id: "234"}] 283 | ...> end 284 | ...> end 285 | ...> end 286 | iex> 287 | iex> Rolodex.RequestBody.to_map(MyRequestBody) 288 | %{ 289 | desc: "A demo request body", 290 | headers: [], 291 | content: %{ 292 | "application/json" => %{ 293 | examples: %{ 294 | request_body: %{id: "123"} 295 | }, 296 | schema: %{ 297 | type: :ref, 298 | ref: Rolodex.RequestBodyTest.MySimpleSchema 299 | } 300 | }, 301 | "application/json-list" => %{ 302 | examples: %{ 303 | request_body: [%{id: "123"}], 304 | another_request_body: [%{id: "234"}], 305 | }, 306 | schema: %{ 307 | type: :list, 308 | of: [ 309 | %{type: :ref, ref: Rolodex.RequestBodyTest.MySimpleSchema} 310 | ] 311 | } 312 | } 313 | } 314 | } 315 | """ 316 | @spec to_map(module()) :: map() 317 | def to_map(mod), do: DSL.to_content_body_map(&mod.__request_body__/1) 318 | 319 | @doc """ 320 | Traverses a serialized Request Body and collects any nested references to any 321 | Schemas within. See `Rolodex.Field.get_refs/1` for more info. 322 | """ 323 | @spec get_refs(module()) :: [module()] 324 | def get_refs(mod), do: DSL.get_refs_in_content_body(&mod.__request_body__/1) 325 | end 326 | -------------------------------------------------------------------------------- /lib/rolodex/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Response do 2 | @moduledoc """ 3 | Exposes functions and macros for defining reusable responses. 4 | 5 | It exposes the following macros, which when used together will setup a response: 6 | 7 | - `response/2` - for declaring a response 8 | - `desc/1` - for setting an (optional) response description 9 | - `content/2` - for defining a response shape for a specific content type 10 | - `schema/1` and `schema/2` - for defining the shape for a content type 11 | - `example/2` - for defining an (optional) response example for a content type 12 | 13 | It also exposes the following functions: 14 | 15 | - `is_response_module?/1` - determines if the provided item is a module that 16 | has defined a reusable response 17 | - `to_map/1` - serializes a response module into a map 18 | - `get_refs/1` - traverses a response and searches for any nested `Rolodex.Schema` 19 | refs within 20 | """ 21 | 22 | alias Rolodex.DSL 23 | 24 | defmacro __using__(_opts) do 25 | quote do 26 | use Rolodex.DSL 27 | import Rolodex.Response, only: :macros 28 | end 29 | end 30 | 31 | @doc """ 32 | Opens up the response definition for the current module. Will name the response 33 | and generate metadata for the response based on macro calls within the provided 34 | block. 35 | 36 | **Accept** 37 | - `name` - the response name 38 | - `block` - response shape definitions 39 | 40 | ## Example 41 | 42 | defmodule MyResponse do 43 | use Rolodex.Response 44 | 45 | response "MyResponse" do 46 | desc "A demo response with multiple content types" 47 | 48 | content "application/json" do 49 | schema MyResponseSchema 50 | 51 | example :response, %{foo: "bar"} 52 | example :other_response, %{bar: "baz"} 53 | end 54 | 55 | content "foo/bar" do 56 | schema AnotherResponseSchema 57 | example :response, %{foo: "bar"} 58 | end 59 | end 60 | end 61 | """ 62 | defmacro response(name, opts) do 63 | DSL.def_content_body(:__response__, name, opts) 64 | end 65 | 66 | @doc """ 67 | Sets a description for the response 68 | """ 69 | defmacro desc(str), do: DSL.set_desc(str) 70 | 71 | @doc """ 72 | Sets headers to be included in the response. You can use a shared headers ref 73 | defined via `Rolodex.Headers`, or just pass in a bare map or keyword list. If 74 | the macro is called multiple times, all headers passed in will be merged together 75 | in the docs result. 76 | 77 | ## Examples 78 | 79 | # Shared headers module 80 | defmodule MyResponse do 81 | use Rolodex.Response 82 | 83 | response "MyResponse" do 84 | headers MyResponseHeaders 85 | headers MyAdditionalResponseHeaders 86 | end 87 | end 88 | 89 | # Headers defined in place 90 | defmodule MyResponse do 91 | use Rolodex.Response 92 | 93 | response "MyResponse" do 94 | headers %{ 95 | "X-Pagination" => %{ 96 | type: :integer, 97 | description: "Pagination information" 98 | } 99 | } 100 | end 101 | end 102 | """ 103 | defmacro headers(metadata), do: DSL.set_headers(metadata) 104 | 105 | @doc """ 106 | Defines a response shape for the given content type key 107 | 108 | **Accepts** 109 | - `key` - a valid content-type key 110 | - `block` - metadata about the response shape for this content type 111 | """ 112 | defmacro content(key, opts) do 113 | DSL.def_content_type_shape(:__response__, key, opts) 114 | end 115 | 116 | @doc """ 117 | Sets an example for the content type. This macro can be used multiple times 118 | within a content type block to allow multiple examples. 119 | 120 | **Accepts** 121 | - `name` - a name for the example 122 | - `body` - a map, which is the example data 123 | """ 124 | defmacro example(name, example_body) do 125 | DSL.set_example(:__response__, name, example_body) 126 | end 127 | 128 | @doc """ 129 | Sets a schema for the current response content type. There are three ways 130 | you can define a schema for a content-type chunk: 131 | 132 | 1. You can pass in an alias for a reusable schema defined via `Rolodex.Schema` 133 | 2. You can define a schema inline via the same macro syntax used in `Rolodex.Schema` 134 | 3. You can define a schema inline via a bare map, which will be parsed with `Rolodex.Field` 135 | 136 | ## Examples 137 | 138 | # Via a reusable schema alias 139 | content "application/json" do 140 | schema MySchema 141 | end 142 | 143 | # Can define a schema inline via the schema + field + partial macros 144 | content "application/json" do 145 | schema do 146 | field :id, :uuid 147 | field :name, :string, desc: "The name" 148 | 149 | partial PaginationParams 150 | end 151 | end 152 | 153 | # Can provide a bare map, which will be parsed via `Rolodex.Field` 154 | content "application/json" do 155 | schema %{ 156 | type: :object, 157 | properties: %{ 158 | id: :uuid, 159 | name: :string 160 | } 161 | } 162 | end 163 | """ 164 | defmacro schema(mod), do: DSL.set_schema(:__response__, mod) 165 | 166 | @doc """ 167 | Sets a collection of schemas for the current response content type. 168 | 169 | ## Examples 170 | 171 | # Response is a list 172 | content "application/json" do 173 | schema :list, of: [MySchema] 174 | end 175 | 176 | # Response is one of the provided types 177 | content "application/json" do 178 | schema :one_of, of: [MySchema, MyOtherSchema] 179 | end 180 | """ 181 | defmacro schema(collection_type, opts) do 182 | DSL.set_schema(:__response__, collection_type, opts) 183 | end 184 | 185 | @doc """ 186 | Adds a new field to the schema when defining a schema inline via macros. See 187 | `Rolodex.Field` for more information about valid field metadata. 188 | 189 | Accepts 190 | - `identifier` - field name 191 | - `type` - either an atom or another Rolodex.Schema module 192 | - `opts` - a keyword list of options, looks for `desc` and `of` (for array types) 193 | 194 | ## Example 195 | 196 | defmodule MyResponse do 197 | use Rolodex.Response 198 | 199 | response "MyResponse" do 200 | content "application/json" do 201 | schema do 202 | # Atomic field with no description 203 | field :id, :uuid 204 | 205 | # Atomic field with a description 206 | field :name, :string, desc: "The object's name" 207 | 208 | # A field that refers to another, nested object 209 | field :other, OtherSchema 210 | 211 | # A field that is an array of items of one-or-more types 212 | field :multi, :list, of: [:string, OtherSchema] 213 | 214 | # You can use a shorthand to define a list field, the below is identical 215 | # to the above 216 | field :multi, [:string, OtherSchema] 217 | 218 | # A field that is one of the possible provided types 219 | field :any, :one_of, of: [:string, OtherSchema] 220 | end 221 | end 222 | end 223 | end 224 | """ 225 | defmacro field(identifier, type, opts \\ []) do 226 | DSL.set_field(:fields, identifier, type, opts) 227 | end 228 | 229 | @doc """ 230 | Adds a new partial to the schema when defining a schema inline via macros. A 231 | partial is another schema that will be serialized and merged into the top-level 232 | properties map for the current schema. Partials are useful for shared parameters 233 | used across multiple schemas. Bare keyword lists and maps that are parseable 234 | by `Rolodex.Field` are also supported. 235 | 236 | ## Example 237 | 238 | defmodule PaginationParams do 239 | use Rolodex.Schema 240 | 241 | schema "PaginationParams" do 242 | field :page, :integer 243 | field :page_size, :integer 244 | field :total_pages, :integer 245 | end 246 | end 247 | 248 | defmodule MyResponse do 249 | use Rolodex.Response 250 | 251 | response "MyResponse" do 252 | content "application/json" do 253 | schema do 254 | field :id, :uuid 255 | partial PaginationParams 256 | end 257 | end 258 | end 259 | end 260 | """ 261 | defmacro partial(mod), do: DSL.set_partial(mod) 262 | 263 | @doc """ 264 | Determines if an arbitrary item is a module that has defined a reusable response 265 | via `Rolodex.Response` macros 266 | 267 | ## Example 268 | 269 | iex> defmodule SimpleResponse do 270 | ...> use Rolodex.Response 271 | ...> response "SimpleResponse" do 272 | ...> content "application/json" do 273 | ...> schema MySchema 274 | ...> end 275 | ...> end 276 | ...> end 277 | iex> 278 | iex> # Validating a response module 279 | iex> Rolodex.Response.is_response_module?(SimpleResponse) 280 | true 281 | iex> # Validating some other module 282 | iex> Rolodex.Response.is_response_module?(OtherModule) 283 | false 284 | """ 285 | @spec is_response_module?(any()) :: boolean() 286 | def is_response_module?(mod), do: DSL.is_module_of_type?(mod, :__response__) 287 | 288 | @doc """ 289 | Serializes the `Rolodex.Response` metadata into a formatted map. 290 | 291 | ## Example 292 | 293 | iex> defmodule MySimpleSchema do 294 | ...> use Rolodex.Schema 295 | ...> 296 | ...> schema "MySimpleSchema" do 297 | ...> field :id, :uuid 298 | ...> end 299 | ...> end 300 | iex> 301 | iex> defmodule MyResponse do 302 | ...> use Rolodex.Response 303 | ...> 304 | ...> response "MyResponse" do 305 | ...> desc "A demo response" 306 | ...> 307 | ...> headers %{"X-Rate-Limited" => :boolean} 308 | ...> 309 | ...> content "application/json" do 310 | ...> schema MySimpleSchema 311 | ...> example :response, %{id: "123"} 312 | ...> end 313 | ...> 314 | ...> content "application/json-list" do 315 | ...> schema [MySimpleSchema] 316 | ...> example :response, [%{id: "123"}] 317 | ...> example :another_response, [%{id: "234"}] 318 | ...> end 319 | ...> end 320 | ...> end 321 | iex> 322 | iex> Rolodex.Response.to_map(MyResponse) 323 | %{ 324 | desc: "A demo response", 325 | headers: [ 326 | %{"X-Rate-Limited" => %{type: :boolean}} 327 | ], 328 | content: %{ 329 | "application/json" => %{ 330 | examples: %{ 331 | response: %{id: "123"} 332 | }, 333 | schema: %{ 334 | type: :ref, 335 | ref: Rolodex.ResponseTest.MySimpleSchema 336 | } 337 | }, 338 | "application/json-list" => %{ 339 | examples: %{ 340 | response: [%{id: "123"}], 341 | another_response: [%{id: "234"}], 342 | }, 343 | schema: %{ 344 | type: :list, 345 | of: [ 346 | %{type: :ref, ref: Rolodex.ResponseTest.MySimpleSchema} 347 | ] 348 | } 349 | } 350 | } 351 | } 352 | """ 353 | @spec to_map(module()) :: map() 354 | def to_map(mod), do: DSL.to_content_body_map(&mod.__response__/1) 355 | 356 | @doc """ 357 | Traverses a serialized Response and collects any nested references to any 358 | Schemas within. See `Rolodex.Field.get_refs/1` for more info. 359 | """ 360 | @spec get_refs(module()) :: [module()] 361 | def get_refs(mod), do: DSL.get_refs_in_content_body(&mod.__response__/1) 362 | end 363 | -------------------------------------------------------------------------------- /lib/rolodex/route.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Route do 2 | @moduledoc """ 3 | Parses and collects documentation metadata for a single Phoenix API route. 4 | 5 | `new/2` generates a `Rolodex.Route.t()`, a struct of route metdata passed into 6 | a `Rolodex.Processor` for serialization. This module also contains information 7 | on how to structure `@doc` annotations for your controller action functions. 8 | 9 | ### Fields 10 | 11 | * **`desc`** (Default: `""`) 12 | 13 | Set via an `@doc` comment 14 | 15 | @doc [ 16 | # Other annotations here 17 | ] 18 | @doc "My route description" 19 | def route(_, _), do: nil 20 | 21 | * **`id`** (Default: `""`) 22 | 23 | Route identifier. Used as an optional unique identifier for the route. 24 | 25 | @doc [ 26 | id: "foobar" 27 | ] 28 | 29 | * **`body`** *(Default: `%{}`) 30 | 31 | Request body parameters. Valid inputs: `Rolodex.RequestBody`, or a map or 32 | keyword list describing a parameter schema. When providing a plain map or 33 | keyword list, the request body schema will be set under the default content 34 | type value set in `Rolodex.Config`. 35 | 36 | @doc [ 37 | # A shared request body defined via `Rolodex.RequestBody` 38 | body: SomeRequestBody, 39 | 40 | # Request body is a JSON object with two parameters: `id` and `name` 41 | body: %{id: :uuid, name: :string}, 42 | body: [id: :uuid, name: :string], 43 | 44 | # Same as above, but here the top-level data structure `type` is specified 45 | # so that we can add `desc` metadata to it 46 | body: %{ 47 | type: :object, 48 | desc: "The request body", 49 | properties: %{id: :uuid} 50 | }, 51 | body: [ 52 | type: :object, 53 | desc: "The request body", 54 | properties: [id: :uuid] 55 | ], 56 | 57 | # Request body is a JSON array of strings 58 | body: [:string], 59 | 60 | # Same as above, but here the top-level data structure `type` is specified 61 | body: %{type: :list, of: [:string]}, 62 | body: [type: :list, of: [:string]], 63 | 64 | # All together 65 | body: [ 66 | id: :uuid, 67 | name: [type: :string, desc: "The name"], 68 | ages: [:number] 69 | ] 70 | ] 71 | 72 | * **`auth`** (Default: `%{}`) 73 | 74 | Define auth requirements for the route. Valid input is a single atom or a list 75 | of auth patterns. We only support logical OR auth definitions: if you provide 76 | a list of auth patterns, Rolodex will serialize this as any one of those auth 77 | patterns is required. 78 | 79 | @doc [ 80 | # Simplest: auth pattern with no scope patterns 81 | auth: :MySimpleAuth, 82 | 83 | # One auth pattern with some scopes 84 | auth: [OAuth: ["user.read"]], 85 | 86 | # Multiple auth patterns 87 | auth: [ 88 | :MySimpleAuth, 89 | OAuth: ["user.read"] 90 | ] 91 | ] 92 | 93 | * **`headers`** (Default: `%{}`) 94 | 95 | Request headers. Valid inputs: a module that has defined shared heads via 96 | `Rolodex.Headers`, or a map or keyword list, where each key is a header name 97 | and each value is a description of the value in the form of a an atom, a map, 98 | or a list. 99 | 100 | Each header value can also specify the following: `minimum` (default: `nil`), 101 | `maximum` (default: `nil`), default (default: `nil`), and required (default: `required`). 102 | 103 | @doc [ 104 | # Shared headers 105 | headers: MySharedRequestHeaders 106 | 107 | # Simplest header description: a name with a concrete type 108 | headers: %{"X-Request-ID" => :uuid}, 109 | headers: ["X-Request-ID": :uuid], 110 | 111 | # Specifying metadata for the header value 112 | headers: %{ 113 | "X-Request-ID" => %{ 114 | type: :integer, 115 | required: true, 116 | minimum: 0, 117 | maximum: 10, 118 | default: 0 119 | } 120 | }, 121 | headers: [ 122 | "X-Request-ID": [ 123 | type: :integer, 124 | required: true, 125 | minimum: 0, 126 | maximum: 10, 127 | default: 0 128 | ] 129 | ], 130 | 131 | # Multiple header values 132 | headers: [ 133 | "X-Request-ID": :uuid, 134 | "Custom-Data": [ 135 | id: :uuid, 136 | checksum: :string 137 | ] 138 | ] 139 | ] 140 | 141 | * **`path_params`** (Default: `%{}`) 142 | 143 | Parameters in the route path. Valid input is a map or keyword list, where each 144 | key is a path parameter name and each value is a description of the value in 145 | the form of an atom, a map, or a list. Another valid input is a `Rolodex.Schema` 146 | module. The attributes at the top-level of the schema will be serialized as 147 | the path parameters. 148 | 149 | Each parameter value can also specify the following: `minimum` (default: 150 | `nil`), `maximum` (default: `nil`), default (default: `nil`), and required 151 | (default: `required`). 152 | 153 | @doc [ 154 | # Reference to a schema module 155 | path_params: PathParamsSchema, 156 | 157 | # Simple inline path parameter description: a name with a concrete type 158 | path_params: %{id: :uuid}, 159 | path_params: [id: :uuid], 160 | 161 | # Specifying metadata for the path value 162 | path_params: %{ 163 | id: %{ 164 | type: :integer, 165 | required: true, 166 | minimum: 0, 167 | maximum: 10, 168 | default: 0 169 | } 170 | }, 171 | path_params: [ 172 | id: [ 173 | type: :integer, 174 | required: true, 175 | minimum: 0, 176 | maximum: 10, 177 | default: 0 178 | ] 179 | ] 180 | ] 181 | 182 | * **`query_params`** (Default: `%{}`) 183 | 184 | Query parameters. Valid input is a map or keyword list, where each key is a 185 | query parameter name and each value is a description of the value in the form 186 | of an atom, a map, or a list. Another valid input is a `Rolodex.Schema` 187 | module. The attributes at the top-level of the schema will be serialized as 188 | the query parameters. 189 | 190 | Each query value can also specify the following: `minimum` (default: `nil`), 191 | `maximum` (default: `nil`), default (default: `nil`), and required (default: 192 | `required`). 193 | 194 | @doc [ 195 | # Reference to a schema module 196 | path_params: QueryParamsSchema, 197 | 198 | # Simple inline query parameter description: a name with a concrete type 199 | query_params: %{id: :uuid}, 200 | query_params: [id: :uuid], 201 | 202 | # Specifying metadata for the parameter value 203 | query_params: %{ 204 | id: %{ 205 | type: :integer, 206 | required: true, 207 | minimum: 0, 208 | maximum: 10, 209 | default: 0 210 | } 211 | }, 212 | query_params: [ 213 | id: [ 214 | type: :integer, 215 | required: true, 216 | minimum: 0, 217 | maximum: 10, 218 | default: 0 219 | ] 220 | ] 221 | ] 222 | 223 | * **`responses`** (Default: `%{}`) 224 | 225 | Response(s) for the route action. Valid input is a map or keyword list, where 226 | each key is a response code and each value is a description of the response in 227 | the form of a `Rolodex.Response`, an atom, a map, or a list. 228 | 229 | @doc [ 230 | responses: %{ 231 | # A response defined via a reusable schema 232 | 200 => MyResponse, 233 | 234 | # Use `:ok` for simple success responses 235 | 200 => :ok, 236 | 237 | # Response is a JSON object with two parameters: `id` and `name` 238 | 200 => %{id: :uuid, name: :string}, 239 | 200 => [id: :uuid, name: :string], 240 | 241 | # Same as above, but here the top-level data structure `type` is specified 242 | # so that we can add `desc` metadata to it 243 | 200 => %{ 244 | type: :object, 245 | desc: "The response body", 246 | properties: %{id: :uuid} 247 | }, 248 | 200 => [ 249 | type: :object, 250 | desc: "The response body", 251 | properties: [id: :uuid] 252 | ], 253 | 254 | # Response is a JSON array of a schema 255 | 200 => [MyResponse], 256 | 257 | # Same as above, but here the top-level data structure `type` is specified 258 | 200 => %{type: :list, of: [MyResponse]}, 259 | 200 => [type: :list, of: [MyResponse]], 260 | 261 | # Response is one of multiple possible results 262 | 200 => %{type: :one_of, of: [MyResponse, OtherResponse]}, 263 | 200 => [type: :one_of, of: [MyResponse, OtherResponse]], 264 | } 265 | ] 266 | 267 | * **`metadata`** (Default: `%{}`) 268 | 269 | Any metadata for the route. Valid input is a map or keyword list. 270 | 271 | * **`tags`** (Default: `[]`) 272 | 273 | Route tags. Valid input is a list of strings. 274 | 275 | ## Handling Route Pipelines 276 | 277 | In your `Rolodex.Config`, you can specify shared route parameters for your 278 | Phoenix pipelines. For each route, if it is part of a pipeline, `new/2` will 279 | merge in shared pipeline config data into the route metadata 280 | 281 | # Your Phoenix router 282 | defmodule MyRouter do 283 | pipeline :api do 284 | plug MyPlug 285 | end 286 | 287 | scope "/api" do 288 | pipe_through [:api] 289 | 290 | get "/test", MyController, :index 291 | end 292 | end 293 | 294 | # Your controller 295 | defmodule MyController do 296 | @doc [ 297 | headers: ["X-Request-ID": uuid], 298 | responses: %{200 => :ok} 299 | ] 300 | @doc "My index action" 301 | def index(conn, _), do: conn 302 | end 303 | 304 | # Your config 305 | config = %Rolodex.Config{ 306 | pipelines: %{ 307 | api: %{ 308 | headers: %{"Shared-Header" => :string} 309 | } 310 | } 311 | } 312 | 313 | # Parsed route 314 | %Rolodex.Route{ 315 | headers: %{ 316 | "X-Request-ID" => %{type: :uuid}, 317 | "Shared-Header" => %{type: :string} 318 | }, 319 | responses: %{200 => :ok} 320 | } 321 | 322 | ## Handling Multi-Path Actions 323 | 324 | Sometimes, a Phoenix controller action function will be used for multiple 325 | API paths. In these cases, you can document the same controller action multiple 326 | times, split across either router path or HTTP method. 327 | 328 | Sometimes, the documentation for each path will differ 329 | significantly. If you would like for each router path to pair with its own 330 | docs, you can use the `multi` flag. 331 | 332 | # Your router 333 | defmodule MyRouter do 334 | scope "/api" do 335 | # Same action used across multiple paths 336 | get "/first", MyController, :index 337 | get "/:id/second", MyController, :index 338 | 339 | # Same action used across multiple HTTP methods 340 | get "/search", MyController, :search 341 | post "/search", MyController :search 342 | end 343 | end 344 | 345 | # Your controller 346 | defmodule MyController do 347 | @doc [ 348 | # Flagged as an action with multiple docs 349 | multi: true, 350 | 351 | # All remaining top-level keys should be router paths 352 | "/api/first": [ 353 | responses: %{200 => MyResponse} 354 | ], 355 | "/api/:id/second": [ 356 | path_params: [ 357 | id: [type: :integer, required: true] 358 | ], 359 | responses: ${200 => MyResponse} 360 | ] 361 | ] 362 | def index(conn, _), do: conn 363 | 364 | @doc [ 365 | multi: true, 366 | get: [ 367 | query_params: SearchQuery 368 | responses: %{200 => MyResponse} 369 | ], 370 | post: [ 371 | body: SearchBody, 372 | responses: %{200 => MyResponse} 373 | ] 374 | ] 375 | def search(conn, _), do: conn 376 | end 377 | """ 378 | 379 | alias Rolodex.{ 380 | Config, 381 | Headers, 382 | PipelineConfig, 383 | Field, 384 | Schema 385 | } 386 | 387 | alias Rolodex.Router.RouteInfo 388 | 389 | import Rolodex.Utils, only: [to_struct: 2, indifferent_find: 2] 390 | 391 | defstruct [ 392 | :path, 393 | :verb, 394 | id: "", 395 | auth: %{}, 396 | body: %{}, 397 | desc: "", 398 | headers: %{}, 399 | metadata: %{}, 400 | path_params: %{}, 401 | pipe_through: [], 402 | query_params: %{}, 403 | responses: %{}, 404 | tags: [] 405 | ] 406 | 407 | @route_info_params [:path, :pipe_through, :verb] 408 | 409 | @type t :: %__MODULE__{ 410 | id: binary(), 411 | auth: map(), 412 | body: map(), 413 | desc: binary(), 414 | headers: %{}, 415 | metadata: %{}, 416 | path: binary(), 417 | path_params: %{}, 418 | pipe_through: [atom()], 419 | query_params: %{}, 420 | responses: %{}, 421 | tags: [binary()], 422 | verb: atom() 423 | } 424 | 425 | @doc """ 426 | Takes a `Rolodex.Router.RouteInfo.t()` and parses the doc annotation metadata 427 | into a structured form a `Rolodex.Processor` can serialize into a docs output. 428 | """ 429 | @spec new(Rolodex.Router.RouteInfo.t() | nil, Rolodex.Config.t()) :: t() | nil 430 | def new(nil, _), do: nil 431 | 432 | def new(route_info, config) do 433 | route_info 434 | |> parse_route_docs(config) 435 | |> build_route(route_info, config) 436 | end 437 | 438 | defp build_route(route_data, route_info, config) do 439 | pipeline_config = fetch_pipeline_config(route_info, config) 440 | 441 | route_info 442 | |> Map.take(@route_info_params) 443 | |> deep_merge(pipeline_config) 444 | |> deep_merge(route_data) 445 | |> to_struct(__MODULE__) 446 | end 447 | 448 | defp parse_route_docs(%RouteInfo{metadata: metadata, desc: desc} = route_info, config), 449 | do: parse_route_docs(metadata, desc, route_info, config) 450 | 451 | defp parse_route_docs(kwl, desc, route_info, config) when is_list(kwl) do 452 | kwl 453 | |> Map.new() 454 | |> parse_route_docs(desc, route_info, config) 455 | end 456 | 457 | defp parse_route_docs(%{multi: true} = metadata, desc, route_info, config) do 458 | metadata 459 | |> get_doc_for_multi_route(route_info) 460 | |> parse_route_docs(desc, route_info, config) 461 | end 462 | 463 | defp parse_route_docs(metadata, desc, _, config) do 464 | metadata 465 | |> parse_param_fields() 466 | |> Map.put(:desc, parse_description(desc, config)) 467 | end 468 | 469 | defp get_doc_for_multi_route(metadata, %RouteInfo{path: path, verb: verb}) do 470 | case indifferent_find(metadata, path) do 471 | nil -> indifferent_find(metadata, verb) 472 | doc -> doc 473 | end 474 | end 475 | 476 | defp parse_param_fields(metadata) do 477 | metadata 478 | |> parse_body() 479 | |> parse_params() 480 | |> parse_auth() 481 | end 482 | 483 | defp parse_body(metadata) do 484 | case Map.get(metadata, :body) do 485 | nil -> metadata 486 | body -> %{metadata | body: Field.new(body)} 487 | end 488 | end 489 | 490 | defp parse_params(metadata) do 491 | [:headers, :path_params, :query_params, :responses] 492 | |> Enum.reduce(metadata, fn key, acc -> 493 | Map.update(acc, key, %{}, &parse_param/1) 494 | end) 495 | end 496 | 497 | defp parse_param(param) when is_atom(param) do 498 | cond do 499 | Headers.is_headers_module?(param) -> Headers.to_map(param) 500 | Schema.is_schema_module?(param) -> Schema.to_map(param) |> Map.get(:properties) 501 | true -> Field.new(param) 502 | end 503 | end 504 | 505 | defp parse_param(param) do 506 | Map.new(param, fn {k, v} -> {k, Field.new(v)} end) 507 | end 508 | 509 | defp parse_auth(metadata) do 510 | auth = 511 | metadata 512 | |> Map.get(:auth, %{}) 513 | |> do_parse_auth() 514 | |> Map.new() 515 | 516 | Map.put(metadata, :auth, auth) 517 | end 518 | 519 | defp do_parse_auth(auth, level \\ 0) 520 | defp do_parse_auth({key, value}, _), do: {key, value} 521 | defp do_parse_auth(auth, 0) when is_atom(auth), do: [{auth, []}] 522 | defp do_parse_auth(auth, _) when is_atom(auth), do: {auth, []} 523 | 524 | defp do_parse_auth(auth, level) when is_list(auth), 525 | do: Enum.map(auth, &do_parse_auth(&1, level + 1)) 526 | 527 | defp do_parse_auth(auth, _), do: auth 528 | 529 | defp parse_description(:none, _), do: "" 530 | 531 | defp parse_description(description, %Config{locale: locale}) when is_map(description) do 532 | Map.get(description, locale, "") 533 | end 534 | 535 | defp parse_description(description, _), do: description 536 | 537 | # Builds shared `Rolodex.PipelineConfig` data for the given route. The config 538 | # result will be empty if the route is not piped through any router pipelines or 539 | # if there is no shared pipelines data in `Rolodex.Config`. 540 | defp fetch_pipeline_config(%RouteInfo{pipe_through: nil}, _), do: %{} 541 | 542 | defp fetch_pipeline_config(_, %Config{pipelines: pipelines}) when map_size(pipelines) == 0, 543 | do: %{} 544 | 545 | defp fetch_pipeline_config(%RouteInfo{pipe_through: pipe_through}, %Config{ 546 | pipelines: pipelines 547 | }) do 548 | Enum.reduce(pipe_through, %{}, fn pt, acc -> 549 | pipeline_config = 550 | pipelines 551 | |> Map.get(pt, %PipelineConfig{}) 552 | |> Map.from_struct() 553 | |> parse_param_fields() 554 | 555 | deep_merge(acc, pipeline_config) 556 | end) 557 | end 558 | 559 | defp deep_merge(left, right), do: Map.merge(left, right, &deep_resolve/3) 560 | defp deep_resolve(_key, left = %{}, right = %{}), do: deep_merge(left, right) 561 | defp deep_resolve(_key, _left, right), do: right 562 | end 563 | -------------------------------------------------------------------------------- /lib/rolodex/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Router do 2 | @moduledoc """ 3 | Macros for defining API routes that Rolodex should document. Functions for 4 | serializing these routes into fully formed docs metadata. 5 | 6 | A Rolodex Router is the entry point when Rolodex is compiling your docs. You 7 | provide the router with two things: 8 | 9 | 1) A Phoenix Router 10 | 2) A list of API paths (HTTP action + full URI path) 11 | 12 | Rolodex then looks up the controller action function associated with each API 13 | path, collects the doc annotations for each, and serializes it all to your docs 14 | output of choice. 15 | 16 | ## Example 17 | 18 | defmodule MyRolodexRouter do 19 | use Rolodex.Router 20 | 21 | alias MyWebApp.PhoenixRouter 22 | 23 | router PhoenixRouter do 24 | # Can use macros that pair with HTTP actions, just like in Phoenix 25 | get "/api/users" 26 | post "/api/users" 27 | put "/api/users/:id" 28 | patch "/api/users/:id" 29 | delete "/api/users/:id" 30 | head "/api/users/:id" 31 | options "/api/users/:id" 32 | 33 | # Our can use the more verbose route/2 macro 34 | route :get, "/api/users" 35 | 36 | # Use routes/2 to define multiple routes that use the same path 37 | routes [:put, :patch, :delete], "/api/users/:id" 38 | end 39 | end 40 | """ 41 | 42 | alias Rolodex.Utils 43 | alias Rolodex.Router.RouteInfo 44 | 45 | defmacro __using__(_) do 46 | quote do 47 | import Rolodex.Router, only: :macros 48 | end 49 | end 50 | 51 | @doc """ 52 | Opens up a definition for a Rolodex router. Used to define which routes Rolodex 53 | should document. 54 | 55 | router MyApp.MyPhoenixRouter do 56 | get "/api/health" 57 | 58 | get "/api/users" 59 | post "/api/users" 60 | put "/api/users/:id" 61 | end 62 | """ 63 | defmacro router(phoenix_router, do: block) do 64 | quote do 65 | Module.register_attribute(__MODULE__, :routes, accumulate: true) 66 | 67 | unquote(block) 68 | 69 | def __router__(:phoenix_router), do: unquote(phoenix_router) 70 | def __router__(:routes), do: @routes 71 | end 72 | end 73 | 74 | @doc """ 75 | Defines a set of routes with different HTTP action verbs at the same path to document 76 | 77 | routes [:get, :put, :delete], "/api/entity/:id" 78 | """ 79 | defmacro routes(verbs, path) when is_list(verbs) do 80 | quote do 81 | unquote(Enum.map(verbs, &set_route(&1, path))) 82 | end 83 | end 84 | 85 | @doc """ 86 | Defines a route with the given HTTP action verb and path to document 87 | 88 | route :get, "/api/entity/:id" 89 | """ 90 | defmacro route(verb, path) when is_atom(verb), do: set_route(verb, path) 91 | 92 | @doc """ 93 | Defines an HTTP GET route at the given path to document 94 | 95 | get "/api/entity/:id" 96 | """ 97 | defmacro get(path), do: set_route(:get, path) 98 | 99 | @doc """ 100 | Defines an HTTP POST route at the given path to document 101 | 102 | post "/api/entity" 103 | """ 104 | defmacro post(path), do: set_route(:post, path) 105 | 106 | @doc """ 107 | Defines an HTTP PUT route at the given path to document 108 | 109 | put "/api/entity/:id" 110 | """ 111 | defmacro put(path), do: set_route(:put, path) 112 | 113 | @doc """ 114 | Defines an HTTP PATCH route at the given path to document 115 | 116 | patch "/api/entity/:id" 117 | """ 118 | defmacro patch(path), do: set_route(:patch, path) 119 | 120 | @doc """ 121 | Defines an HTTP DELETE route at the given path to document 122 | 123 | delete "/api/entity/:id" 124 | """ 125 | defmacro delete(path), do: set_route(:delete, path) 126 | 127 | @doc """ 128 | Defines an HTTP HEAD route at the given path to document 129 | 130 | head "/api/entity/:id" 131 | """ 132 | defmacro head(path), do: set_route(:head, path) 133 | 134 | @doc """ 135 | Defines an HTTP OPTIONS route at the given path to document 136 | 137 | options "/api/entity/:id" 138 | """ 139 | defmacro options(path), do: set_route(:options, path) 140 | 141 | defp set_route(verb, path) do 142 | quote do 143 | @routes {unquote(verb), unquote(path)} 144 | end 145 | end 146 | 147 | @doc """ 148 | Collects all the routes defined in a `Rolodex.Router` into a list of fully 149 | serialized `Rolodex.Route` structs. 150 | """ 151 | @spec build_routes(module(), Rolodex.Config.t()) :: [Rolodex.Route.t()] 152 | def build_routes(router_mod, config) do 153 | phoenix_router = router_mod.__router__(:phoenix_router) 154 | 155 | router_mod.__router__(:routes) 156 | |> Enum.reduce([], fn {verb, path}, routes -> 157 | # If Rolodex can't find a matching Phoenix route OR if the associated 158 | # controller action has no doc annotation, `build_route/4` will return `nil` 159 | # and we strip it from the results 160 | case build_route(verb, path, phoenix_router, config) do 161 | nil -> routes 162 | route -> [route | routes] 163 | end 164 | end) 165 | end 166 | 167 | defp build_route(verb, path, phoenix_router, config) do 168 | http_action_string = verb |> Atom.to_string() |> String.upcase() 169 | 170 | phoenix_router 171 | |> Phoenix.Router.route_info(http_action_string, path, "") 172 | |> RouteInfo.from_route_info(verb) 173 | |> with_doc_annotation() 174 | |> Rolodex.Route.new(config) 175 | end 176 | 177 | defp with_doc_annotation(%RouteInfo{controller: controller, action: action} = info) do 178 | case Utils.fetch_doc_annotation(controller, action) do 179 | {:error, :not_found} -> nil 180 | {:ok, desc, metadata} -> %{info | desc: desc, metadata: metadata} 181 | end 182 | end 183 | 184 | defp with_doc_annotation(_), do: nil 185 | end 186 | -------------------------------------------------------------------------------- /lib/rolodex/router/route_info.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Router.RouteInfo do 2 | @moduledoc false 3 | 4 | import Rolodex.Utils, only: [to_struct: 2] 5 | 6 | defstruct [ 7 | :action, 8 | :controller, 9 | :desc, 10 | :metadata, 11 | :path, 12 | :pipe_through, 13 | :verb 14 | ] 15 | 16 | @type t :: %__MODULE__{ 17 | action: atom(), 18 | controller: module(), 19 | desc: binary(), 20 | metadata: map() | list() | nil, 21 | path: binary(), 22 | pipe_through: list(), 23 | verb: atom() 24 | } 25 | 26 | def new(params), do: params |> Map.new() |> to_struct(__MODULE__) 27 | 28 | def from_route_info( 29 | %{plug: controller, plug_opts: action, route: path, pipe_through: pipe_through}, 30 | verb 31 | ) do 32 | %__MODULE__{ 33 | controller: controller, 34 | action: action, 35 | path: path, 36 | verb: verb, 37 | pipe_through: pipe_through 38 | } 39 | end 40 | 41 | def from_route_info(_, _), do: nil 42 | end 43 | -------------------------------------------------------------------------------- /lib/rolodex/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Schema do 2 | @moduledoc """ 3 | Exposes functions and macros for defining reusable parameter schemas. 4 | 5 | It includes two macros. Used together, you can setup a reusable schema: 6 | 7 | - `schema/3` - for declaring a schema 8 | - `field/3` - for declaring schema fields 9 | 10 | It also exposes the following functions: 11 | 12 | - `is_schema_module?/1` - determines if the provided item is a module that has 13 | defined a reuseable schema 14 | - `to_map/1` - serializes a schema module into a map for use by a `Rolodex.Processor` 15 | behaviour 16 | - `get_refs/1` - traverses a schema and searches for any nested schemas within 17 | """ 18 | 19 | alias Rolodex.{DSL, Field} 20 | 21 | defmacro __using__(_opts) do 22 | quote do 23 | use Rolodex.DSL 24 | import Rolodex.Schema 25 | end 26 | end 27 | 28 | @doc """ 29 | Opens up the schema definition for the current module. Will name the schema 30 | and generate metadata for the schema based on subsequent calls to `field/3` 31 | 32 | **Accepts** 33 | - `name` - the schema name 34 | - `opts` - a keyword list of options (currently, only looks for a `desc` key) 35 | - `block` - the inner schema definition with one or more calls to `field/3` 36 | 37 | ## Example 38 | 39 | defmodule MySchema do 40 | use Rolodex.Schema 41 | 42 | schema "MySchema", desc: "Example schema" do 43 | # Atomic field with no description 44 | field :id, :uuid 45 | 46 | # Atomic field with a description 47 | field :name, :string, desc: "The object's name" 48 | 49 | # A field that refers to another, nested object 50 | field :other, OtherSchema 51 | 52 | # A field that is an array of items of one-or-more types 53 | field :multi, :list, of: [:string, OtherSchema] 54 | 55 | # A field that is one of the possible provided types 56 | field :any, :one_of, of: [:string, OtherSchema] 57 | 58 | # Treats OtherSchema as a partial to be merged into this schema 59 | partial OtherSchema 60 | end 61 | end 62 | """ 63 | defmacro schema(name, opts \\ [], do: block) do 64 | schema_body_ast = DSL.set_schema(:__schema__, do: block) 65 | 66 | quote do 67 | unquote(schema_body_ast) 68 | 69 | def __schema__(:name), do: unquote(name) 70 | def __schema__(:desc), do: unquote(Keyword.get(opts, :desc, nil)) 71 | end 72 | end 73 | 74 | @doc """ 75 | Adds a new field to the schema. See `Rolodex.Field` for more information about 76 | valid field metadata. 77 | 78 | Accepts 79 | - `identifier` - field name 80 | - `type` - either an atom or another Rolodex.Schema module 81 | - `opts` - a keyword list of options, looks for `desc` and `of` (for array types) 82 | 83 | ## Example 84 | 85 | defmodule MySchema do 86 | use Rolodex.Schema 87 | 88 | schema "MySchema", desc: "Example schema" do 89 | # Atomic field with no description 90 | field :id, :uuid 91 | 92 | # Atomic field with a description 93 | field :name, :string, desc: "The object's name" 94 | 95 | # A field that refers to another, nested object 96 | field :other, OtherSchema 97 | 98 | # A field that is an array of items of one-or-more types 99 | field :multi, :list, of: [:string, OtherSchema] 100 | 101 | # You can use a shorthand to define a list field, the below is identical 102 | # to the above 103 | field :multi, [:string, OtherSchema] 104 | 105 | # A field that is one of the possible provided types 106 | field :any, :one_of, of: [:string, OtherSchema] 107 | end 108 | end 109 | """ 110 | defmacro field(identifier, type, opts \\ []) do 111 | DSL.set_field(:fields, identifier, type, opts) 112 | end 113 | 114 | @doc """ 115 | Adds a new partial to the schema. A partial is another schema that will be 116 | serialized and merged into the top-level properties map for the current schema. 117 | Partials are useful for shared parameters used across multiple schemas. Bare 118 | keyword lists and maps that are parseable by `Rolodex.Field` are also supported. 119 | 120 | ## Example 121 | 122 | defmodule AgeSchema do 123 | use Rolodex.Schema 124 | 125 | schema "AgeSchema" do 126 | field :age, :integer 127 | field :date_of_birth, :datetime 128 | field :city_of_birth, :string 129 | end 130 | end 131 | 132 | defmodule MySchema do 133 | use Rolodex.Schema 134 | 135 | schema "MySchema" do 136 | field :id, :uuid 137 | field :name, :string 138 | 139 | # A partial via another schema 140 | partial AgeSchema 141 | 142 | # A partial via a bare keyword list 143 | partial [ 144 | city: :string, 145 | state: :string, 146 | country: :string 147 | ] 148 | end 149 | end 150 | 151 | # MySchema will be serialized by `to_map/1` as: 152 | %{ 153 | type: :object, 154 | desc: nil, 155 | properties: %{ 156 | id: %{type: :uuid}, 157 | name: %{type: :string}, 158 | 159 | # From the AgeSchema partial 160 | age: %{type: :integer}, 161 | date_of_birth: %{type: :datetime} 162 | city_of_birth: %{type: :string}, 163 | 164 | # From the keyword list partial 165 | city: %{type: :string}, 166 | state: %{type: :string}, 167 | country: %{type: :string} 168 | } 169 | } 170 | """ 171 | defmacro partial(mod), do: DSL.set_partial(mod) 172 | 173 | @doc """ 174 | Determines if an arbitrary item is a module that has defined a reusable schema 175 | via `Rolodex.Schema` macros 176 | 177 | ## Example 178 | 179 | iex> defmodule SimpleSchema do 180 | ...> use Rolodex.Schema 181 | ...> schema "SimpleSchema", desc: "Demo schema" do 182 | ...> field :id, :uuid 183 | ...> end 184 | ...> end 185 | iex> 186 | iex> # Validating a schema module 187 | iex> Rolodex.Schema.is_schema_module?(SimpleSchema) 188 | true 189 | iex> # Validating some other module 190 | iex> Rolodex.Schema.is_schema_module?(OtherModule) 191 | false 192 | """ 193 | @spec is_schema_module?(any()) :: boolean() 194 | def is_schema_module?(mod), do: DSL.is_module_of_type?(mod, :__schema__) 195 | 196 | @doc """ 197 | Serializes the `Rolodex.Schema` metadata into a formatted map. 198 | 199 | ## Example 200 | 201 | iex> defmodule OtherSchema do 202 | ...> use Rolodex.Schema 203 | ...> 204 | ...> schema "OtherSchema" do 205 | ...> field :id, :uuid 206 | ...> end 207 | ...> end 208 | iex> 209 | iex> defmodule MySchema do 210 | ...> use Rolodex.Schema 211 | ...> 212 | ...> schema "MySchema", desc: "An example" do 213 | ...> # Atomic field with no description 214 | ...> field :id, :uuid 215 | ...> 216 | ...> # Atomic field with a description 217 | ...> field :name, :string, desc: "The schema's name" 218 | ...> 219 | ...> # A field that refers to another, nested object 220 | ...> field :other, OtherSchema 221 | ...> 222 | ...> # A field that is an array of items of one-or-more types 223 | ...> field :multi, :list, of: [:string, OtherSchema] 224 | ...> 225 | ...> # A field that is one of the possible provided types 226 | ...> field :any, :one_of, of: [:string, OtherSchema] 227 | ...> end 228 | ...> end 229 | iex> 230 | iex> Rolodex.Schema.to_map(MySchema) 231 | %{ 232 | type: :object, 233 | desc: "An example", 234 | properties: %{ 235 | id: %{type: :uuid}, 236 | name: %{desc: "The schema's name", type: :string}, 237 | other: %{type: :ref, ref: Rolodex.SchemaTest.OtherSchema}, 238 | multi: %{ 239 | type: :list, 240 | of: [ 241 | %{type: :string}, 242 | %{type: :ref, ref: Rolodex.SchemaTest.OtherSchema} 243 | ] 244 | }, 245 | any: %{ 246 | type: :one_of, 247 | of: [ 248 | %{type: :string}, 249 | %{type: :ref, ref: Rolodex.SchemaTest.OtherSchema} 250 | ] 251 | } 252 | } 253 | } 254 | """ 255 | @spec to_map(module()) :: map() 256 | def to_map(mod) do 257 | mod.__schema__({nil, :schema}) 258 | |> Map.put(:desc, mod.__schema__(:desc)) 259 | end 260 | 261 | @doc """ 262 | Traverses a serialized Schema and collects any nested references to other 263 | Schemas within. See `Rolodex.Field.get_refs/1` for more info. 264 | """ 265 | @spec get_refs(module()) :: [module()] 266 | def get_refs(mod) do 267 | mod 268 | |> to_map() 269 | |> Field.get_refs() 270 | end 271 | end 272 | -------------------------------------------------------------------------------- /lib/rolodex/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Utils do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Pipeline friendly dynamic struct creator 6 | """ 7 | def to_struct(data, module), do: struct(module, data) 8 | 9 | @doc """ 10 | Recursively convert a keyword list into a map 11 | """ 12 | def to_map_deep(data, level \\ 0) 13 | def to_map_deep([], 0), do: %{} 14 | 15 | def to_map_deep(list, level) when is_list(list) do 16 | case Keyword.keyword?(list) do 17 | true -> Map.new(list, fn {key, val} -> {key, to_map_deep(val, level + 1)} end) 18 | false -> list 19 | end 20 | end 21 | 22 | def to_map_deep(data, _), do: data 23 | 24 | @doc """ 25 | Recursively convert all keys in a map from snake_case to camelCase 26 | """ 27 | def camelize_map(data) when not is_map(data), do: data 28 | 29 | def camelize_map(data) do 30 | Map.new(data, fn {key, value} -> {camelize(key), camelize_map(value)} end) 31 | end 32 | 33 | defp camelize(key) when is_atom(key), do: key |> Atom.to_string() |> camelize() 34 | 35 | defp camelize(key) do 36 | case Macro.camelize(key) do 37 | ^key -> key 38 | camelized -> uncapitalize(camelized) 39 | end 40 | end 41 | 42 | defp uncapitalize(<>), do: String.downcase(<>) <> rest 43 | 44 | @doc """ 45 | Similar to Ruby's `with_indifferent_access`, this function performs an indifferent 46 | key lookup on a map or keyword list. Indifference means that the keys :foo and 47 | "foo" are considered identical. We only convert from atom -> string to avoid 48 | the unsafe `String.to_atom/1` function. 49 | """ 50 | @spec indifferent_find(map() | keyword(), atom() | binary()) :: any() 51 | def indifferent_find(data, key) when is_atom(key), 52 | do: indifferent_find(data, Atom.to_string(key)) 53 | 54 | def indifferent_find(data, key) do 55 | data 56 | |> Enum.find(fn 57 | {k, _} when is_atom(k) -> Atom.to_string(k) == key 58 | {k, _} -> k == key 59 | end) 60 | |> case do 61 | {_, result} -> result 62 | _ -> nil 63 | end 64 | end 65 | 66 | @doc """ 67 | Grabs the description and metadata map associated with the given function via 68 | `@doc` annotations. 69 | """ 70 | @spec fetch_doc_annotation(module(), atom()) :: {:ok, binary(), map()} | {:error, :not_found} 71 | def fetch_doc_annotation(controller, action) do 72 | controller 73 | |> Code.fetch_docs() 74 | |> Tuple.to_list() 75 | |> Enum.at(-1) 76 | |> Enum.find(fn 77 | {{:function, ^action, _arity}, _, _, _, _} -> true 78 | _ -> false 79 | end) 80 | |> case do 81 | {_, _, _, desc, metadata} -> {:ok, desc, metadata} 82 | _ -> {:error, :not_found} 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/rolodex/writers/file_writer.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Writers.FileWriter do 2 | @behaviour Rolodex.Writer 3 | 4 | @impl Rolodex.Writer 5 | def init(opts) do 6 | with {:ok, file_name} <- fetch_file_name(opts), 7 | {:ok, cwd} <- File.cwd(), 8 | full_path <- Path.join([cwd, file_name]), 9 | :ok <- File.touch(full_path) do 10 | File.open(full_path, [:write]) 11 | end 12 | end 13 | 14 | @impl Rolodex.Writer 15 | def write(io_device, content) do 16 | IO.write(io_device, content) 17 | end 18 | 19 | @impl Rolodex.Writer 20 | def close(io_device) do 21 | File.close(io_device) 22 | end 23 | 24 | defp fetch_file_name(opts) when is_list(opts) do 25 | opts 26 | |> Map.new() 27 | |> fetch_file_name() 28 | end 29 | 30 | defp fetch_file_name(%{file_name: name}) when is_binary(name) and name != "", 31 | do: {:ok, name} 32 | 33 | defp fetch_file_name(_), do: {:error, :file_name_missing} 34 | end 35 | -------------------------------------------------------------------------------- /lib/rolodex/writers/mock.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Writers.Mock do 2 | @behaviour Rolodex.Writer 3 | 4 | @impl Rolodex.Writer 5 | def init(_), do: {:ok, :stdio} 6 | 7 | @impl Rolodex.Writer 8 | def write(io_device, content), do: IO.write(io_device, content) 9 | 10 | @impl Rolodex.Writer 11 | def close(:stdio), do: :ok 12 | end 13 | -------------------------------------------------------------------------------- /lib/rolodex/writers/writer.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Writer do 2 | @moduledoc """ 3 | A behavior to write to arbitrary entities. 4 | """ 5 | 6 | @doc """ 7 | Should implement a way to write to a `IO.device()`. 8 | """ 9 | @callback write(IO.device(), String.t()) :: :ok 10 | 11 | @doc """ 12 | Returns an open `IO.device()` for writing. 13 | """ 14 | @callback init(list() | map()) :: {:ok, IO.device()} | {:error, any} 15 | 16 | @doc """ 17 | Closes the given `IO.device()`. 18 | """ 19 | @callback close(IO.device()) :: :ok | {:error, any} 20 | end 21 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :rolodex, 7 | name: "Rolodex", 8 | description: "Automated docs generation", 9 | version: "0.10.1", 10 | elixir: "~> 1.7", 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | start_permanent: Mix.env() == :prod, 13 | dialyzer: [plt_add_apps: [:mix]], 14 | deps: deps(), 15 | package: package() 16 | ] 17 | end 18 | 19 | # Run "mix help compile.app" to learn about applications. 20 | def application() do 21 | [ 22 | extra_applications: [:logger] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Run "mix help deps" to learn about dependencies. 31 | defp deps() do 32 | [ 33 | # {:dep_from_hexpm, "~> 0.3.0"}, 34 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, 35 | {:jason, "~> 1.1"}, 36 | {:phoenix, ">= 1.4.7"}, 37 | {:dialyxir, "~> 1.0.0-rc.4", only: [:dev], runtime: false}, 38 | {:ex_doc, ">= 0.0.0", only: :dev} 39 | ] 40 | end 41 | 42 | defp package() do 43 | [ 44 | licenses: ["MIT"], 45 | links: %{"GitHub" => "https://github.com/Frameio/rolodex"} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.0.0-rc.4", "71b42f5ee1b7628f3e3a6565f4617dfb02d127a0499ab3e72750455e986df001", [:mix], [{:erlex, "~> 0.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"}, 4 | "erlex": {:hex, :erlex, "0.1.6", "c01c889363168d3fdd23f4211647d8a34c0f9a21ec726762312e08e083f3d47e", [:mix], [], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "flow": {:hex, :flow, "0.14.3", "0d92991fe53035894d24aa8dec10dcfccf0ae00c4ed436ace3efa9813a646902", [:mix], [{:gen_stage, "~> 0.14.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "gen_stage": {:hex, :gen_stage, "0.14.1", "9d46723fda072d4f4bb31a102560013f7960f5d80ea44dcb96fd6304ed61e7a4", [:mix], [], "hexpm"}, 8 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 9 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 13 | "phoenix": {:hex, :phoenix, "1.4.9", "746d098e10741c334d88143d3c94cab1756435f94387a63441792e66ec0ee974", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, 15 | "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, 16 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 17 | "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, 18 | } 19 | -------------------------------------------------------------------------------- /test/rolodex/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.ConfigTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.{Config, PipelineConfig, RenderGroupConfig} 5 | alias Rolodex.Mocks.TestRouter 6 | 7 | defmodule BasicConfig do 8 | use Rolodex.Config 9 | 10 | def spec() do 11 | [ 12 | description: "Hello world", 13 | title: "BasicConfig", 14 | version: "0.0.1" 15 | ] 16 | end 17 | end 18 | 19 | defmodule FullConfig do 20 | use Rolodex.Config 21 | 22 | def spec() do 23 | [ 24 | description: "Hello world", 25 | title: "BasicConfig", 26 | version: "0.0.1" 27 | ] 28 | end 29 | 30 | def render_groups_spec() do 31 | [ 32 | [router: TestRouter, writer_opts: [file_name: "api-public.json"]], 33 | [router: TestRouter, writer_opts: [file_name: "api-private.json"]] 34 | ] 35 | end 36 | 37 | def pipelines_spec() do 38 | [ 39 | api: [ 40 | auth: :JWTAuth, 41 | headers: ["X-Request-ID": :uuid] 42 | ] 43 | ] 44 | end 45 | 46 | def auth_spec() do 47 | [ 48 | JWTAuth: [ 49 | type: "http", 50 | scheme: "bearer" 51 | ], 52 | TokenAuth: [type: "oauth2"], 53 | OAuth: [ 54 | type: "oauth2", 55 | flows: [ 56 | authorization_code: [ 57 | authorization_url: "https://applications.frame.io/oauth2/authorize", 58 | token_url: "https://applications.frame.io/oauth2/token", 59 | scopes: [ 60 | "user.read", 61 | "account.read", 62 | "account.write" 63 | ] 64 | ] 65 | ] 66 | ] 67 | ] 68 | end 69 | end 70 | 71 | describe "#new/1" do 72 | test "It parses a basic config with no writer and pipeline overrides" do 73 | assert Config.new(BasicConfig) == %Config{ 74 | description: "Hello world", 75 | locale: "en", 76 | pipelines: %{}, 77 | title: "BasicConfig", 78 | version: "0.0.1", 79 | render_groups: [%RenderGroupConfig{}] 80 | } 81 | end 82 | 83 | test "It parses a full config with writer and pipeline overrides" do 84 | assert Config.new(FullConfig) == %Config{ 85 | description: "Hello world", 86 | locale: "en", 87 | pipelines: %{ 88 | api: PipelineConfig.new(headers: ["X-Request-ID": :uuid], auth: :JWTAuth) 89 | }, 90 | auth: %{ 91 | JWTAuth: %{ 92 | type: "http", 93 | scheme: "bearer" 94 | }, 95 | TokenAuth: %{type: "oauth2"}, 96 | OAuth: %{ 97 | type: "oauth2", 98 | flows: %{ 99 | authorization_code: %{ 100 | authorization_url: "https://applications.frame.io/oauth2/authorize", 101 | token_url: "https://applications.frame.io/oauth2/token", 102 | scopes: [ 103 | "user.read", 104 | "account.read", 105 | "account.write" 106 | ] 107 | } 108 | } 109 | } 110 | }, 111 | title: "BasicConfig", 112 | version: "0.0.1", 113 | render_groups: [ 114 | %RenderGroupConfig{ 115 | router: TestRouter, 116 | writer_opts: [file_name: "api-public.json"] 117 | }, 118 | %RenderGroupConfig{ 119 | router: TestRouter, 120 | writer_opts: [file_name: "api-private.json"] 121 | } 122 | ] 123 | } 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /test/rolodex/field_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.FieldTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.Field 5 | 6 | alias Rolodex.Mocks.{ 7 | Comment, 8 | User 9 | } 10 | 11 | doctest Field 12 | 13 | describe "new/1" do 14 | test "It will return an empty map when an empty map or list is provided" do 15 | assert Field.new([]) == %{} 16 | assert Field.new(%{}) == %{} 17 | end 18 | 19 | test "It can create a field" do 20 | assert Field.new(:string) == %{type: :string} 21 | end 22 | 23 | test "It resolves top-level Rolodex.Schema refs" do 24 | field = Field.new(type: :list, of: [User, Comment, :string]) 25 | 26 | assert field == %{ 27 | type: :list, 28 | of: [ 29 | %{type: :ref, ref: User}, 30 | %{type: :ref, ref: Comment}, 31 | %{type: :string} 32 | ] 33 | } 34 | end 35 | 36 | test "It handles objects as bare maps" do 37 | field = Field.new(type: :object, properties: %{id: :string, nested: User}) 38 | 39 | assert field == %{ 40 | type: :object, 41 | properties: %{ 42 | id: %{type: :string}, 43 | nested: %{type: :ref, ref: User} 44 | } 45 | } 46 | end 47 | 48 | test "It handles objects with already created Fields" do 49 | field = 50 | Field.new( 51 | type: :object, 52 | properties: %{id: Field.new(:string), nested: Field.new(type: User)} 53 | ) 54 | 55 | assert field == %{ 56 | type: :object, 57 | properties: %{ 58 | id: %{type: :string}, 59 | nested: %{type: :ref, ref: User} 60 | } 61 | } 62 | end 63 | 64 | test "It handles object shorthand" do 65 | field = Field.new(id: :uuid, name: :string, nested: User) 66 | 67 | assert field == %{ 68 | type: :object, 69 | properties: %{ 70 | id: %{type: :uuid}, 71 | name: %{type: :string}, 72 | nested: %{type: :ref, ref: User} 73 | } 74 | } 75 | end 76 | 77 | test "It handles list shorthand" do 78 | field = Field.new([:uuid, User]) 79 | 80 | assert field == %{ 81 | type: :list, 82 | of: [ 83 | %{type: :uuid}, 84 | %{type: :ref, ref: User} 85 | ] 86 | } 87 | end 88 | end 89 | 90 | describe "#get_refs/1" do 91 | test "It gets schema refs as top-level fields" do 92 | refs = Field.new(type: User) |> Field.get_refs() 93 | 94 | assert refs == [User] 95 | end 96 | 97 | test "It gets schema refs in collections" do 98 | refs = 99 | Field.new(type: :list, of: [User, Comment, :string]) 100 | |> Field.get_refs() 101 | 102 | assert refs == [Comment, User] 103 | end 104 | 105 | test "It gets schema refs in nested properties" do 106 | refs = 107 | Field.new(type: :object, properties: %{id: :string, nested: User}) 108 | |> Field.get_refs() 109 | 110 | assert refs == [User] 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/rolodex/headers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.HeadersTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.Headers 5 | alias Rolodex.Mocks.PaginationHeaders 6 | 7 | doctest Headers 8 | 9 | describe "#headers/2 macro" do 10 | test "It generates headers metadata" do 11 | assert PaginationHeaders.__headers__(:name) == "PaginationHeaders" 12 | 13 | assert PaginationHeaders.__headers__(:headers) == %{ 14 | "total" => %{type: :integer, desc: "Total entries to be retrieved"}, 15 | "per-page" => %{ 16 | type: :integer, 17 | desc: "Total entries per page of results", 18 | required: true 19 | } 20 | } 21 | end 22 | end 23 | 24 | describe "#is_headers_module?/1" do 25 | test "It returns the expected result" do 26 | assert Headers.is_headers_module?(PaginationHeaders) 27 | refute Headers.is_headers_module?(UnusedAlias) 28 | end 29 | end 30 | 31 | describe "#to_map/1" do 32 | test "It returns the serialized headers" do 33 | assert Headers.to_map(PaginationHeaders) == %{ 34 | "total" => %{type: :integer, desc: "Total entries to be retrieved"}, 35 | "per-page" => %{ 36 | type: :integer, 37 | desc: "Total entries per page of results", 38 | required: true 39 | } 40 | } 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/rolodex/processors/open_api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Processors.OpenAPITest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.{ 5 | Config, 6 | Response, 7 | Route, 8 | RequestBody, 9 | Schema, 10 | Headers 11 | } 12 | 13 | alias Rolodex.Processors.OpenAPI 14 | 15 | alias Rolodex.Mocks.{ 16 | ErrorResponse, 17 | User, 18 | UserRequestBody, 19 | UserResponse, 20 | RateLimitHeaders 21 | } 22 | 23 | defmodule(BasicConfig, do: use(Rolodex.Config)) 24 | 25 | defmodule FullConfig do 26 | use Rolodex.Config 27 | 28 | def spec() do 29 | [ 30 | description: "foo", 31 | title: "bar", 32 | version: "1", 33 | server_urls: ["https://api.example.com"] 34 | ] 35 | end 36 | 37 | def auth_spec() do 38 | [ 39 | JWTAuth: [ 40 | type: "http", 41 | scheme: "bearer" 42 | ], 43 | OAuth: [ 44 | type: "oauth2", 45 | flows: [ 46 | authorization_code: [ 47 | authorization_url: "https://applications.frame.io/oauth2/authorize", 48 | token_url: "https://applications.frame.io/oauth2/token", 49 | scopes: [ 50 | "user.read", 51 | "account.read", 52 | "account.write" 53 | ] 54 | ] 55 | ] 56 | ] 57 | ] 58 | end 59 | end 60 | 61 | describe "#process/3" do 62 | test "Processes config, routes, and schemas into a serialized JSON blob" do 63 | config = Config.new(FullConfig) 64 | 65 | refs = %{ 66 | request_bodies: %{ 67 | UserRequestBody => RequestBody.to_map(UserRequestBody) 68 | }, 69 | responses: %{ 70 | UserResponse => Response.to_map(UserResponse) 71 | }, 72 | schemas: %{ 73 | User => Schema.to_map(User) 74 | }, 75 | headers: %{ 76 | RateLimitHeaders => Headers.to_map(RateLimitHeaders) 77 | } 78 | } 79 | 80 | routes = [ 81 | %Route{ 82 | id: "foo", 83 | auth: %{ 84 | JWTAuth: [], 85 | OAuth: ["user.read"] 86 | }, 87 | desc: "It does a thing", 88 | path: "/foo", 89 | verb: :get, 90 | body: %{type: :ref, ref: UserRequestBody}, 91 | responses: %{ 92 | 200 => %{type: :ref, ref: UserResponse} 93 | } 94 | } 95 | ] 96 | 97 | result = OpenAPI.process(config, routes, refs) |> Jason.decode!() 98 | 99 | assert result == %{ 100 | "openapi" => "3.0.0", 101 | "info" => %{ 102 | "title" => config.title, 103 | "description" => config.description, 104 | "version" => config.version 105 | }, 106 | "servers" => [%{"url" => "https://api.example.com"}], 107 | "paths" => %{ 108 | "/foo" => %{ 109 | "get" => %{ 110 | "operationId" => "foo", 111 | "summary" => "It does a thing", 112 | "tags" => [], 113 | "security" => [ 114 | %{"JWTAuth" => []}, 115 | %{"OAuth" => ["user.read"]} 116 | ], 117 | "parameters" => [], 118 | "requestBody" => %{ 119 | "$ref" => "#/components/requestBodies/UserRequestBody" 120 | }, 121 | "responses" => %{ 122 | "200" => %{ 123 | "$ref" => "#/components/responses/UserResponse" 124 | } 125 | } 126 | } 127 | } 128 | }, 129 | "components" => %{ 130 | "requestBodies" => %{ 131 | "UserRequestBody" => %{ 132 | "content" => %{ 133 | "application/json" => %{ 134 | "examples" => %{ 135 | "request" => %{"value" => %{"id" => "1"}} 136 | }, 137 | "schema" => %{ 138 | "$ref" => "#/components/schemas/User" 139 | } 140 | } 141 | }, 142 | "description" => "A single user entity request body" 143 | } 144 | }, 145 | "responses" => %{ 146 | "UserResponse" => %{ 147 | "content" => %{ 148 | "application/json" => %{ 149 | "examples" => %{ 150 | "response" => %{"value" => %{"id" => "1"}} 151 | }, 152 | "schema" => %{ 153 | "$ref" => "#/components/schemas/User" 154 | } 155 | } 156 | }, 157 | "headers" => %{ 158 | "limited" => %{ 159 | "description" => "Have you been rate limited", 160 | "schema" => %{"type" => "boolean"} 161 | } 162 | }, 163 | "description" => "A single user entity response" 164 | } 165 | }, 166 | "schemas" => %{ 167 | "User" => %{ 168 | "type" => "object", 169 | "description" => "A user record", 170 | "required" => ["id", "email"], 171 | "properties" => %{ 172 | "id" => %{ 173 | "type" => "string", 174 | "format" => "uuid", 175 | "description" => "The id of the user" 176 | }, 177 | "email" => %{ 178 | "type" => "string", 179 | "description" => "The email of the user" 180 | }, 181 | "comment" => %{ 182 | "$ref" => "#/components/schemas/Comment" 183 | }, 184 | "comments" => %{ 185 | "type" => "array", 186 | "items" => %{ 187 | "$ref" => "#/components/schemas/Comment" 188 | } 189 | }, 190 | "short_comments" => %{ 191 | "type" => "array", 192 | "items" => %{ 193 | "$ref" => "#/components/schemas/Comment" 194 | } 195 | }, 196 | "comments_of_many_types" => %{ 197 | "type" => "array", 198 | "description" => "List of text or comment", 199 | "items" => %{ 200 | "oneOf" => [ 201 | %{ 202 | "type" => "string" 203 | }, 204 | %{ 205 | "$ref" => "#/components/schemas/Comment" 206 | } 207 | ] 208 | } 209 | }, 210 | "multi" => %{ 211 | "oneOf" => [ 212 | %{ 213 | "type" => "string" 214 | }, 215 | %{"$ref" => "#/components/schemas/NotFound"} 216 | ] 217 | }, 218 | "parent" => %{ 219 | "$ref" => "#/components/schemas/Parent" 220 | }, 221 | "private" => %{ 222 | "type" => "boolean" 223 | }, 224 | "archived" => %{ 225 | "type" => "boolean" 226 | }, 227 | "active" => %{ 228 | "type" => "boolean" 229 | } 230 | } 231 | } 232 | }, 233 | "securitySchemes" => %{ 234 | "JWTAuth" => %{ 235 | "type" => "http", 236 | "scheme" => "bearer" 237 | }, 238 | "OAuth" => %{ 239 | "type" => "oauth2", 240 | "flows" => %{ 241 | "authorizationCode" => %{ 242 | "authorizationUrl" => "https://applications.frame.io/oauth2/authorize", 243 | "tokenUrl" => "https://applications.frame.io/oauth2/token", 244 | "scopes" => [ 245 | "user.read", 246 | "account.read", 247 | "account.write" 248 | ] 249 | } 250 | } 251 | } 252 | } 253 | } 254 | } 255 | end 256 | end 257 | 258 | describe "#process_headers/1" do 259 | test "It returns a map of top-level metadata" do 260 | config = Config.new(FullConfig) 261 | 262 | headers = OpenAPI.process_headers(config) 263 | 264 | assert headers == %{ 265 | openapi: "3.0.0", 266 | servers: [%{url: "https://api.example.com"}], 267 | info: %{ 268 | title: config.title, 269 | description: config.description, 270 | version: config.version 271 | } 272 | } 273 | end 274 | end 275 | 276 | describe "#process_routes/1" do 277 | test "It takes a list of routes and refs and returns a formatted map" do 278 | routes = [ 279 | %Route{ 280 | id: "foo", 281 | desc: "It does a thing", 282 | auth: %{JWTAuth: []}, 283 | headers: %{ 284 | "X-Request-Id" => %{type: :uuid, required: true} 285 | }, 286 | body: %{type: :ref, ref: UserRequestBody}, 287 | query_params: %{ 288 | id: %{ 289 | type: :integer, 290 | maximum: 10, 291 | minimum: 0, 292 | required: true, 293 | default: 2 294 | }, 295 | update: %{type: :boolean} 296 | }, 297 | path_params: %{ 298 | account_id: %{type: :uuid, desc: "The account id"} 299 | }, 300 | responses: %{ 301 | 200 => %{type: :ref, ref: UserResponse}, 302 | 201 => :ok, 303 | 404 => %{type: :ref, ref: ErrorResponse} 304 | }, 305 | path: "/foo", 306 | verb: :get 307 | } 308 | ] 309 | 310 | processed = OpenAPI.process_routes(routes, Config.new(BasicConfig)) 311 | 312 | assert processed == %{ 313 | "/foo" => %{ 314 | get: %{ 315 | operationId: "foo", 316 | summary: "It does a thing", 317 | tags: [], 318 | security: [%{JWTAuth: []}], 319 | parameters: [ 320 | %{ 321 | in: :header, 322 | name: "X-Request-Id", 323 | required: true, 324 | schema: %{ 325 | type: :string, 326 | format: :uuid 327 | } 328 | }, 329 | %{ 330 | in: :path, 331 | name: :account_id, 332 | description: "The account id", 333 | schema: %{ 334 | type: :string, 335 | format: :uuid 336 | } 337 | }, 338 | %{ 339 | in: :query, 340 | name: :id, 341 | required: true, 342 | schema: %{ 343 | type: :integer, 344 | maximum: 10, 345 | minimum: 0, 346 | default: 2 347 | } 348 | }, 349 | %{ 350 | in: :query, 351 | name: :update, 352 | schema: %{ 353 | type: :boolean 354 | } 355 | } 356 | ], 357 | requestBody: %{ 358 | "$ref" => "#/components/requestBodies/UserRequestBody" 359 | }, 360 | responses: %{ 361 | 200 => %{ 362 | "$ref" => "#/components/responses/UserResponse" 363 | }, 364 | 201 => %{description: "OK"}, 365 | 404 => %{ 366 | "$ref" => "#/components/responses/ErrorResponse" 367 | } 368 | } 369 | } 370 | } 371 | } 372 | end 373 | 374 | test "It collects routes by path" do 375 | routes = [ 376 | %Route{ 377 | id: "foo", 378 | path: "/foo", 379 | verb: :get, 380 | desc: "GET /foo", 381 | responses: %{200 => %{type: :ref, ref: UserResponse}} 382 | }, 383 | %Route{ 384 | id: "foobar", 385 | path: "/foo/:id", 386 | verb: :get, 387 | desc: "GET /foo/{id}", 388 | responses: %{200 => %{type: :ref, ref: UserResponse}} 389 | }, 390 | %Route{ 391 | id: "foobuzz", 392 | path: "/foo/:id", 393 | verb: :post, 394 | desc: "POST /foo/{id}", 395 | responses: %{200 => %{type: :ref, ref: UserResponse}} 396 | } 397 | ] 398 | 399 | assert OpenAPI.process_routes(routes, Config.new(BasicConfig)) == %{ 400 | "/foo" => %{ 401 | get: %{ 402 | operationId: "foo", 403 | summary: "GET /foo", 404 | security: [], 405 | parameters: [], 406 | tags: [], 407 | responses: %{ 408 | 200 => %{ 409 | "$ref" => "#/components/responses/UserResponse" 410 | } 411 | } 412 | } 413 | }, 414 | "/foo/{id}" => %{ 415 | get: %{ 416 | operationId: "foobar", 417 | summary: "GET /foo/{id}", 418 | security: [], 419 | parameters: [], 420 | tags: [], 421 | responses: %{ 422 | 200 => %{ 423 | "$ref" => "#/components/responses/UserResponse" 424 | } 425 | } 426 | }, 427 | post: %{ 428 | operationId: "foobuzz", 429 | summary: "POST /foo/{id}", 430 | security: [], 431 | parameters: [], 432 | tags: [], 433 | responses: %{ 434 | 200 => %{ 435 | "$ref" => "#/components/responses/UserResponse" 436 | } 437 | } 438 | } 439 | } 440 | } 441 | end 442 | 443 | test "It processes fields that should become formatted strings" do 444 | routes = [ 445 | %Route{ 446 | id: "foo", 447 | path: "/foo", 448 | verb: :get, 449 | desc: "GET /foo", 450 | query_params: %{ 451 | id: %{type: :uuid}, 452 | email: %{type: :email}, 453 | url: %{type: :uri}, 454 | date: %{type: :date}, 455 | date_and_time: %{type: :datetime}, 456 | other_date_and_time: %{type: :"date-time"}, 457 | pass: %{type: :password}, 458 | chunk: %{type: :byte}, 459 | chunks: %{type: :binary} 460 | }, 461 | responses: %{200 => %{type: :ref, ref: UserResponse}} 462 | } 463 | ] 464 | 465 | assert OpenAPI.process_routes(routes, Config.new(BasicConfig)) == %{ 466 | "/foo" => %{ 467 | get: %{ 468 | operationId: "foo", 469 | summary: "GET /foo", 470 | security: [], 471 | tags: [], 472 | parameters: [ 473 | %{ 474 | in: :query, 475 | name: :chunk, 476 | schema: %{ 477 | type: :string, 478 | format: :byte 479 | } 480 | }, 481 | %{ 482 | in: :query, 483 | name: :chunks, 484 | schema: %{ 485 | type: :string, 486 | format: :binary 487 | } 488 | }, 489 | %{ 490 | in: :query, 491 | name: :date, 492 | schema: %{ 493 | type: :string, 494 | format: :date 495 | } 496 | }, 497 | %{ 498 | in: :query, 499 | name: :date_and_time, 500 | schema: %{ 501 | type: :string, 502 | format: :"date-time" 503 | } 504 | }, 505 | %{ 506 | in: :query, 507 | name: :email, 508 | schema: %{ 509 | type: :string, 510 | format: :email 511 | } 512 | }, 513 | %{ 514 | in: :query, 515 | name: :id, 516 | schema: %{ 517 | type: :string, 518 | format: :uuid 519 | } 520 | }, 521 | %{ 522 | in: :query, 523 | name: :other_date_and_time, 524 | schema: %{ 525 | type: :string, 526 | format: :"date-time" 527 | } 528 | }, 529 | %{ 530 | in: :query, 531 | name: :pass, 532 | schema: %{ 533 | type: :string, 534 | format: :password 535 | } 536 | }, 537 | %{ 538 | in: :query, 539 | name: :url, 540 | schema: %{ 541 | type: :string, 542 | format: :uri 543 | } 544 | } 545 | ], 546 | responses: %{ 547 | 200 => %{ 548 | "$ref" => "#/components/responses/UserResponse" 549 | } 550 | } 551 | } 552 | } 553 | } 554 | end 555 | end 556 | 557 | describe "#process_refs/1" do 558 | test "It processes the response and schema refs" do 559 | refs = %{ 560 | request_bodies: %{ 561 | UserRequestBody => RequestBody.to_map(UserRequestBody) 562 | }, 563 | responses: %{ 564 | UserResponse => Response.to_map(UserResponse) 565 | }, 566 | schemas: %{ 567 | User => Schema.to_map(User) 568 | }, 569 | headers: %{ 570 | RateLimitHeaders => Headers.to_map(RateLimitHeaders) 571 | } 572 | } 573 | 574 | assert OpenAPI.process_refs(refs, Config.new(FullConfig)) == %{ 575 | requestBodies: %{ 576 | "UserRequestBody" => %{ 577 | content: %{ 578 | "application/json" => %{ 579 | examples: %{request: %{value: %{id: "1"}}}, 580 | schema: %{ 581 | "$ref" => "#/components/schemas/User" 582 | } 583 | } 584 | }, 585 | description: "A single user entity request body" 586 | } 587 | }, 588 | responses: %{ 589 | "UserResponse" => %{ 590 | content: %{ 591 | "application/json" => %{ 592 | examples: %{response: %{value: %{id: "1"}}}, 593 | schema: %{ 594 | "$ref" => "#/components/schemas/User" 595 | } 596 | } 597 | }, 598 | headers: %{ 599 | "limited" => %{ 600 | description: "Have you been rate limited", 601 | schema: %{type: :boolean} 602 | } 603 | }, 604 | description: "A single user entity response" 605 | } 606 | }, 607 | schemas: %{ 608 | "User" => %{ 609 | type: :object, 610 | description: "A user record", 611 | required: [:id, :email], 612 | properties: %{ 613 | id: %{ 614 | type: :string, 615 | format: :uuid, 616 | description: "The id of the user" 617 | }, 618 | email: %{ 619 | type: :string, 620 | description: "The email of the user" 621 | }, 622 | comment: %{ 623 | "$ref" => "#/components/schemas/Comment" 624 | }, 625 | comments: %{ 626 | type: :array, 627 | items: %{ 628 | "$ref" => "#/components/schemas/Comment" 629 | } 630 | }, 631 | short_comments: %{ 632 | type: :array, 633 | items: %{ 634 | "$ref" => "#/components/schemas/Comment" 635 | } 636 | }, 637 | comments_of_many_types: %{ 638 | type: :array, 639 | description: "List of text or comment", 640 | items: %{ 641 | oneOf: [ 642 | %{ 643 | type: :string 644 | }, 645 | %{ 646 | "$ref" => "#/components/schemas/Comment" 647 | } 648 | ] 649 | } 650 | }, 651 | multi: %{ 652 | oneOf: [ 653 | %{ 654 | type: :string 655 | }, 656 | %{"$ref" => "#/components/schemas/NotFound"} 657 | ] 658 | }, 659 | parent: %{ 660 | "$ref" => "#/components/schemas/Parent" 661 | }, 662 | private: %{type: :boolean}, 663 | archived: %{type: :boolean}, 664 | active: %{type: :boolean} 665 | } 666 | } 667 | }, 668 | securitySchemes: %{ 669 | "JWTAuth" => %{ 670 | "type" => "http", 671 | "scheme" => "bearer" 672 | }, 673 | "OAuth" => %{ 674 | "type" => "oauth2", 675 | "flows" => %{ 676 | "authorizationCode" => %{ 677 | "authorizationUrl" => "https://applications.frame.io/oauth2/authorize", 678 | "tokenUrl" => "https://applications.frame.io/oauth2/token", 679 | "scopes" => [ 680 | "user.read", 681 | "account.read", 682 | "account.write" 683 | ] 684 | } 685 | } 686 | } 687 | } 688 | } 689 | end 690 | end 691 | end 692 | -------------------------------------------------------------------------------- /test/rolodex/request_body_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.RequestBodyTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.RequestBody 5 | 6 | alias Rolodex.Mocks.{ 7 | UserRequestBody, 8 | UsersRequestBody, 9 | PaginatedUsersRequestBody, 10 | ParentsRequestBody, 11 | MultiRequestBody, 12 | User, 13 | Comment, 14 | Parent, 15 | InlineMacroSchemaRequest 16 | } 17 | 18 | doctest RequestBody 19 | 20 | describe "#request_body/2 macro" do 21 | test "It sets the expected getter functions" do 22 | assert UserRequestBody.__request_body__(:name) == "UserRequestBody" 23 | assert UserRequestBody.__request_body__(:content_types) == ["application/json"] 24 | end 25 | end 26 | 27 | describe "#desc/1 macro" do 28 | test "It sets the expected getter function" do 29 | assert UserRequestBody.__request_body__(:desc) == "A single user entity request body" 30 | end 31 | 32 | test "Description is not required" do 33 | assert MultiRequestBody.__request_body__(:desc) == nil 34 | end 35 | end 36 | 37 | describe "#content/2 macro" do 38 | test "It sets the expected getter function" do 39 | assert UserRequestBody.__request_body__({"application/json", :examples}) == [:request] 40 | end 41 | end 42 | 43 | describe "#example/2 macro" do 44 | test "It sets the expected getter function" do 45 | assert UserRequestBody.__request_body__({"application/json", :examples, :request}) == %{ 46 | id: "1" 47 | } 48 | end 49 | end 50 | 51 | describe "#schema/1 macro" do 52 | test "It handles a schema module" do 53 | assert UserRequestBody.__request_body__({"application/json", :schema}) == %{ 54 | type: :ref, 55 | ref: User 56 | } 57 | end 58 | 59 | test "It handles a list" do 60 | assert UsersRequestBody.__request_body__({"application/json", :schema}) == %{ 61 | type: :list, 62 | of: [%{type: :ref, ref: User}] 63 | } 64 | end 65 | 66 | test "It handles a bare map" do 67 | assert PaginatedUsersRequestBody.__request_body__({"application/json", :schema}) == %{ 68 | type: :object, 69 | properties: %{ 70 | total: %{type: :integer}, 71 | page: %{type: :integer}, 72 | users: %{ 73 | type: :list, 74 | of: [%{type: :ref, ref: User}] 75 | } 76 | } 77 | } 78 | end 79 | 80 | test "It handles an inline macro" do 81 | assert InlineMacroSchemaRequest.__request_body__({"application/json", :schema}) == %{ 82 | type: :object, 83 | properties: %{ 84 | created_at: %{type: :datetime}, 85 | id: %{type: :uuid, desc: "The comment id"}, 86 | text: %{type: :string}, 87 | mentions: %{type: :list, of: [%{type: :uuid}]} 88 | } 89 | } 90 | end 91 | end 92 | 93 | describe "#schema/2 macro" do 94 | test "It handles a list" do 95 | assert ParentsRequestBody.__request_body__({"application/json", :schema}) == %{ 96 | type: :list, 97 | of: [%{type: :ref, ref: Parent}] 98 | } 99 | end 100 | end 101 | 102 | describe "#is_request_body_module?/2" do 103 | test "It detects if the module has defined a request body via the macros" do 104 | assert RequestBody.is_request_body_module?(UserRequestBody) 105 | assert !RequestBody.is_request_body_module?(User) 106 | end 107 | end 108 | 109 | describe "#to_map/1" do 110 | test "It serializes the request body as expected" do 111 | assert RequestBody.to_map(PaginatedUsersRequestBody) == %{ 112 | desc: "A paginated list of user entities", 113 | headers: [], 114 | content: %{ 115 | "application/json" => %{ 116 | schema: %{ 117 | type: :object, 118 | properties: %{ 119 | total: %{type: :integer}, 120 | page: %{type: :integer}, 121 | users: %{ 122 | type: :list, 123 | of: [%{type: :ref, ref: User}] 124 | } 125 | } 126 | }, 127 | examples: %{ 128 | request: [%{id: "1"}] 129 | } 130 | } 131 | } 132 | } 133 | end 134 | end 135 | 136 | describe "#get_refs/1" do 137 | test "It gets refs within a request body module" do 138 | assert RequestBody.get_refs(MultiRequestBody) == [Comment, User] 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /test/rolodex/response_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.ResponseTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.Response 5 | 6 | alias Rolodex.Mocks.{ 7 | UserResponse, 8 | UsersResponse, 9 | PaginatedUsersResponse, 10 | RateLimitHeaders, 11 | ParentsResponse, 12 | MultiResponse, 13 | User, 14 | Comment, 15 | Parent, 16 | PaginationHeaders, 17 | InlineMacroSchemaResponse 18 | } 19 | 20 | doctest Response 21 | 22 | describe "#response/2 macro" do 23 | test "It sets the expected getter functions" do 24 | assert UserResponse.__response__(:name) == "UserResponse" 25 | assert UserResponse.__response__(:content_types) == ["application/json"] 26 | end 27 | end 28 | 29 | describe "#desc/1 macro" do 30 | test "It sets the expected getter function" do 31 | assert UserResponse.__response__(:desc) == "A single user entity response" 32 | end 33 | 34 | test "Description is not required" do 35 | assert MultiResponse.__response__(:desc) == nil 36 | end 37 | end 38 | 39 | describe "#set_headers/1 macro" do 40 | test "It handles a shared headers module" do 41 | assert UsersResponse.__response__(:headers) == [ 42 | %{ 43 | type: :ref, 44 | ref: PaginationHeaders 45 | } 46 | ] 47 | end 48 | 49 | test "It handles a bare map or kwl" do 50 | assert ParentsResponse.__response__(:headers) == [ 51 | %{ 52 | "total" => %{type: :integer}, 53 | "per-page" => %{type: :integer, required: true} 54 | } 55 | ] 56 | end 57 | 58 | test "It handles multiple headers" do 59 | assert MultiResponse.__response__(:headers) == [ 60 | %{ 61 | type: :ref, 62 | ref: PaginationHeaders 63 | }, 64 | %{ 65 | type: :ref, 66 | ref: RateLimitHeaders 67 | } 68 | ] 69 | end 70 | end 71 | 72 | describe "#content/2 macro" do 73 | test "It sets the expected getter function" do 74 | assert UserResponse.__response__({"application/json", :examples}) == [:response] 75 | end 76 | end 77 | 78 | describe "#example/2 macro" do 79 | test "It sets the expected getter function" do 80 | assert UserResponse.__response__({"application/json", :examples, :response}) == %{id: "1"} 81 | end 82 | end 83 | 84 | describe "#schema/1 macro" do 85 | test "It handles a schema module" do 86 | assert UserResponse.__response__({"application/json", :schema}) == %{ 87 | type: :ref, 88 | ref: User 89 | } 90 | end 91 | 92 | test "It handles a list" do 93 | assert UsersResponse.__response__({"application/json", :schema}) == %{ 94 | type: :list, 95 | of: [%{type: :ref, ref: User}] 96 | } 97 | end 98 | 99 | test "It handles a bare map" do 100 | assert PaginatedUsersResponse.__response__({"application/json", :schema}) == %{ 101 | type: :object, 102 | properties: %{ 103 | total: %{type: :integer}, 104 | page: %{type: :integer}, 105 | users: %{ 106 | type: :list, 107 | of: [%{type: :ref, ref: User}] 108 | } 109 | } 110 | } 111 | end 112 | 113 | test "It handles an inline macro" do 114 | assert InlineMacroSchemaResponse.__response__({"application/json", :schema}) == %{ 115 | type: :object, 116 | properties: %{ 117 | created_at: %{type: :datetime}, 118 | id: %{type: :uuid, desc: "The comment id"}, 119 | text: %{type: :string}, 120 | mentions: %{type: :list, of: [%{type: :uuid}]} 121 | } 122 | } 123 | end 124 | end 125 | 126 | describe "#schema/2 macro" do 127 | test "It handles a list" do 128 | assert ParentsResponse.__response__({"application/json", :schema}) == %{ 129 | type: :list, 130 | of: [%{type: :ref, ref: Parent}] 131 | } 132 | end 133 | end 134 | 135 | describe "#is_response_module?/2" do 136 | test "It detects if the module has defined a response via the macros" do 137 | assert Response.is_response_module?(UserResponse) 138 | assert !Response.is_response_module?(User) 139 | end 140 | end 141 | 142 | describe "#to_map/1" do 143 | test "It serializes the response as expected" do 144 | assert Response.to_map(PaginatedUsersResponse) == %{ 145 | desc: "A paginated list of user entities", 146 | headers: [ 147 | %{ 148 | type: :ref, 149 | ref: PaginationHeaders 150 | } 151 | ], 152 | content: %{ 153 | "application/json" => %{ 154 | schema: %{ 155 | type: :object, 156 | properties: %{ 157 | total: %{type: :integer}, 158 | page: %{type: :integer}, 159 | users: %{ 160 | type: :list, 161 | of: [%{type: :ref, ref: User}] 162 | } 163 | } 164 | }, 165 | examples: %{ 166 | response: [%{id: "1"}] 167 | } 168 | } 169 | } 170 | } 171 | end 172 | end 173 | 174 | describe "#get_refs/1" do 175 | test "It gets refs within a response module" do 176 | assert Response.get_refs(MultiResponse) == [ 177 | Comment, 178 | PaginationHeaders, 179 | RateLimitHeaders, 180 | User 181 | ] 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /test/rolodex/route_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.RouteTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.Mocks.{ 5 | TestController, 6 | UserResponse, 7 | PaginatedUsersResponse, 8 | ErrorResponse, 9 | UserRequestBody 10 | } 11 | 12 | alias Rolodex.{ 13 | Config, 14 | Route, 15 | Utils 16 | } 17 | 18 | alias Rolodex.Router.RouteInfo 19 | 20 | defmodule(BasicConfig, do: use(Rolodex.Config)) 21 | 22 | defmodule FullConfig do 23 | use Rolodex.Config 24 | 25 | def pipelines_spec() do 26 | %{ 27 | api: %{ 28 | auth: :SharedAuth, 29 | headers: %{"X-Request-Id" => %{type: :uuid, required: true}}, 30 | query_params: %{foo: :string} 31 | }, 32 | web: %{ 33 | headers: %{"X-Request-Id" => %{type: :uuid, required: true}}, 34 | query_params: %{foo: :string, bar: :boolean} 35 | }, 36 | socket: %{ 37 | headers: %{bar: :baz} 38 | } 39 | } 40 | end 41 | end 42 | 43 | describe "#new/2" do 44 | setup [:setup_config] 45 | 46 | test "It builds a new Rolodex.Route for the specified controller action", %{config: config} do 47 | result = 48 | setup_route_info( 49 | controller: TestController, 50 | action: :index, 51 | verb: :get, 52 | path: "/v2/test", 53 | pipe_through: [] 54 | ) 55 | |> Route.new(config) 56 | 57 | assert result == %Route{ 58 | auth: %{ 59 | JWTAuth: [], 60 | TokenAuth: ["user.read"], 61 | OAuth: ["user.read"] 62 | }, 63 | desc: "It's a test!", 64 | headers: %{ 65 | "total" => %{type: :integer, desc: "Total entries to be retrieved"}, 66 | "per-page" => %{ 67 | type: :integer, 68 | required: true, 69 | desc: "Total entries per page of results" 70 | } 71 | }, 72 | body: %{type: :ref, ref: UserRequestBody}, 73 | query_params: %{ 74 | id: %{ 75 | type: :string, 76 | maximum: 10, 77 | minimum: 0, 78 | required: false, 79 | default: 2 80 | }, 81 | update: %{type: :boolean} 82 | }, 83 | path_params: %{ 84 | account_id: %{type: :uuid} 85 | }, 86 | responses: %{ 87 | 200 => %{type: :ref, ref: UserResponse}, 88 | 201 => %{type: :ref, ref: PaginatedUsersResponse}, 89 | 404 => %{type: :ref, ref: ErrorResponse} 90 | }, 91 | metadata: %{public: true}, 92 | tags: ["foo", "bar"], 93 | path: "/v2/test", 94 | pipe_through: [], 95 | verb: :get 96 | } 97 | end 98 | 99 | test "It merges controller action params into pipeline params", %{config: config} do 100 | result = 101 | setup_route_info( 102 | controller: TestController, 103 | action: :index, 104 | verb: :get, 105 | path: "/v2/test", 106 | pipe_through: [:web] 107 | ) 108 | |> Route.new(config) 109 | 110 | assert result == %Route{ 111 | auth: %{ 112 | JWTAuth: [], 113 | TokenAuth: ["user.read"], 114 | OAuth: ["user.read"] 115 | }, 116 | desc: "It's a test!", 117 | headers: %{ 118 | "X-Request-Id" => %{type: :uuid, required: true}, 119 | "total" => %{type: :integer, desc: "Total entries to be retrieved"}, 120 | "per-page" => %{ 121 | type: :integer, 122 | required: true, 123 | desc: "Total entries per page of results" 124 | } 125 | }, 126 | body: %{type: :ref, ref: UserRequestBody}, 127 | query_params: %{ 128 | id: %{ 129 | type: :string, 130 | maximum: 10, 131 | minimum: 0, 132 | required: false, 133 | default: 2 134 | }, 135 | update: %{type: :boolean}, 136 | foo: %{type: :string}, 137 | bar: %{type: :boolean} 138 | }, 139 | path_params: %{ 140 | account_id: %{type: :uuid} 141 | }, 142 | responses: %{ 143 | 200 => %{type: :ref, ref: UserResponse}, 144 | 201 => %{type: :ref, ref: PaginatedUsersResponse}, 145 | 404 => %{type: :ref, ref: ErrorResponse} 146 | }, 147 | metadata: %{public: true}, 148 | tags: ["foo", "bar"], 149 | path: "/v2/test", 150 | pipe_through: [:web], 151 | verb: :get 152 | } 153 | end 154 | 155 | test "It uses the Phoenix route path to pull out docs for a multi-headed controller action", 156 | %{ 157 | config: config 158 | } do 159 | result = 160 | setup_route_info( 161 | controller: TestController, 162 | action: :multi, 163 | verb: :get, 164 | path: "/api/nested/:nested_id/multi", 165 | pipe_through: [] 166 | ) 167 | |> Route.new(config) 168 | 169 | assert result == %Route{ 170 | auth: %{JWTAuth: []}, 171 | desc: "It's an action used for multiple routes", 172 | path_params: %{ 173 | nested_id: %{type: :uuid, required: true} 174 | }, 175 | responses: %{ 176 | 200 => %{type: :ref, ref: UserResponse}, 177 | 404 => %{type: :ref, ref: ErrorResponse} 178 | }, 179 | path: "/api/nested/:nested_id/multi", 180 | verb: :get, 181 | pipe_through: [] 182 | } 183 | end 184 | 185 | test "It uses the Phoenix route verb to pull out docs for a multi-headed controller action", 186 | %{ 187 | config: config 188 | } do 189 | result = 190 | setup_route_info( 191 | controller: TestController, 192 | action: :verb_multi, 193 | verb: :post, 194 | path: "/api/nested/:nested_id/multi", 195 | pipe_through: [] 196 | ) 197 | |> Route.new(config) 198 | 199 | assert result == %Route{ 200 | auth: %{JWTAuth: []}, 201 | desc: "It's an action used for the same path with multiple HTTP actions", 202 | path_params: %{ 203 | nested_id: %{type: :uuid, required: true} 204 | }, 205 | responses: %{ 206 | 200 => %{type: :ref, ref: UserResponse}, 207 | 404 => %{type: :ref, ref: ErrorResponse} 208 | }, 209 | path: "/api/nested/:nested_id/multi", 210 | verb: :post, 211 | pipe_through: [] 212 | } 213 | end 214 | 215 | test "Controller action params will win if in conflict with pipeline params", %{ 216 | config: config 217 | } do 218 | %Route{auth: auth, headers: headers} = 219 | setup_route_info( 220 | controller: TestController, 221 | action: :conflicted, 222 | verb: :get, 223 | path: "/v2/test", 224 | pipe_through: [:api] 225 | ) 226 | |> Route.new(config) 227 | 228 | assert headers == %{"X-Request-Id" => %{type: :string, required: true}} 229 | assert auth == %{JWTAuth: [], SharedAuth: []} 230 | end 231 | 232 | test "It processes request body and responses with plain maps", %{config: config} do 233 | %Route{body: body, responses: responses} = 234 | setup_route_info( 235 | controller: TestController, 236 | action: :with_bare_maps, 237 | verb: :get, 238 | path: "/v2/test", 239 | pipe_through: [] 240 | ) 241 | |> Route.new(config) 242 | 243 | assert body == %{ 244 | type: :object, 245 | properties: %{id: %{type: :uuid}} 246 | } 247 | 248 | assert responses == %{ 249 | 200 => %{ 250 | type: :object, 251 | properties: %{id: %{type: :uuid}} 252 | } 253 | } 254 | end 255 | 256 | test "It serializes query and path param schema refs", %{config: config} do 257 | %Route{query_params: query, path_params: path} = 258 | setup_route_info( 259 | controller: TestController, 260 | action: :params_via_schema, 261 | verb: :get, 262 | path: "/v2/test", 263 | pipe_through: [] 264 | ) 265 | |> Route.new(config) 266 | 267 | assert query == %{ 268 | account_id: %{type: :uuid}, 269 | team_id: %{ 270 | type: :integer, 271 | maximum: 10, 272 | minimum: 0, 273 | required: true, 274 | default: 2 275 | }, 276 | created_at: %{type: :datetime}, 277 | id: %{type: :uuid, desc: "The comment id"}, 278 | text: %{type: :string}, 279 | mentions: %{type: :list, of: [%{type: :uuid}]} 280 | } 281 | 282 | assert path == %{ 283 | account_id: %{type: :uuid}, 284 | team_id: %{ 285 | type: :integer, 286 | maximum: 10, 287 | minimum: 0, 288 | required: true, 289 | default: 2 290 | }, 291 | created_at: %{type: :datetime}, 292 | id: %{type: :uuid, desc: "The comment id"}, 293 | text: %{type: :string}, 294 | mentions: %{type: :list, of: [%{type: :uuid}]} 295 | } 296 | end 297 | 298 | test "It handles an undocumented route" do 299 | result = 300 | setup_route_info( 301 | controller: TestController, 302 | action: :undocumented, 303 | verb: :post, 304 | path: "/v2/test", 305 | pipe_through: [] 306 | ) 307 | |> Route.new(Config.new(BasicConfig)) 308 | 309 | assert result == %Route{ 310 | desc: "", 311 | headers: %{}, 312 | body: %{}, 313 | query_params: %{}, 314 | responses: %{}, 315 | metadata: %{}, 316 | tags: [], 317 | path: "/v2/test", 318 | pipe_through: [], 319 | verb: :post 320 | } 321 | end 322 | end 323 | 324 | defp setup_config(_), do: [config: Config.new(FullConfig)] 325 | 326 | defp setup_route_info(%RouteInfo{controller: controller, action: action} = route_info) do 327 | with {:ok, desc, metadata} <- Utils.fetch_doc_annotation(controller, action) do 328 | %{route_info | desc: desc, metadata: metadata} 329 | end 330 | end 331 | 332 | defp setup_route_info(params), do: params |> RouteInfo.new() |> setup_route_info() 333 | end 334 | -------------------------------------------------------------------------------- /test/rolodex/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.RouterTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.{Config, Route, Router} 5 | 6 | alias Rolodex.Mocks.{ 7 | TestPhoenixRouter, 8 | TestRouter, 9 | UserRequestBody, 10 | UserResponse, 11 | PaginatedUsersResponse, 12 | ErrorResponse, 13 | MultiResponse 14 | } 15 | 16 | defmodule(BasicConfig, do: use(Config)) 17 | 18 | describe "macros" do 19 | test "It sets the expected private functions on the router module" do 20 | assert TestRouter.__router__(:phoenix_router) == TestPhoenixRouter 21 | 22 | assert TestRouter.__router__(:routes) == [ 23 | put: "/api/demo/missing/:id", 24 | get: "/api/partials", 25 | get: "/api/nested/:nested_id/multi", 26 | get: "/api/multi", 27 | delete: "/api/demo/:id", 28 | put: "/api/demo/:id", 29 | post: "/api/demo/:id", 30 | get: "/api/demo" 31 | ] 32 | end 33 | end 34 | 35 | describe "#build_routes/2" do 36 | test "It builds %Rolodex.Route{} structs from the router data" do 37 | result = Router.build_routes(TestRouter, Config.new(BasicConfig)) 38 | 39 | assert result == [ 40 | %Route{ 41 | auth: %{JWTAuth: [], OAuth: ["user.read"], TokenAuth: ["user.read"]}, 42 | body: %{ref: UserRequestBody, type: :ref}, 43 | desc: "It's a test!", 44 | headers: %{ 45 | "per-page" => %{ 46 | desc: "Total entries per page of results", 47 | required: true, 48 | type: :integer 49 | }, 50 | "total" => %{desc: "Total entries to be retrieved", type: :integer} 51 | }, 52 | id: "", 53 | metadata: %{public: true}, 54 | path: "/api/demo", 55 | path_params: %{account_id: %{type: :uuid}}, 56 | query_params: %{ 57 | id: %{default: 2, maximum: 10, minimum: 0, required: false, type: :string}, 58 | update: %{type: :boolean} 59 | }, 60 | responses: %{ 61 | 200 => %{ref: UserResponse, type: :ref}, 62 | 201 => %{ref: PaginatedUsersResponse, type: :ref}, 63 | 404 => %{ref: ErrorResponse, type: :ref} 64 | }, 65 | tags: ["foo", "bar"], 66 | verb: :get 67 | }, 68 | %Route{ 69 | auth: %{JWTAuth: []}, 70 | headers: %{"X-Request-Id" => %{type: :string}}, 71 | path: "/api/demo/:id", 72 | verb: :post 73 | }, 74 | %Route{ 75 | body: %{properties: %{id: %{type: :uuid}}, type: :object}, 76 | headers: %{"X-Request-Id" => %{required: true, type: :uuid}}, 77 | path: "/api/demo/:id", 78 | responses: %{200 => %{properties: %{id: %{type: :uuid}}, type: :object}}, 79 | verb: :put 80 | }, 81 | %Route{ 82 | path: "/api/demo/:id", 83 | verb: :delete 84 | }, 85 | %Route{ 86 | auth: %{JWTAuth: []}, 87 | desc: "It's an action used for multiple routes", 88 | path: "/api/multi", 89 | responses: %{ 90 | 200 => %{ref: UserResponse, type: :ref}, 91 | 201 => %{ref: MultiResponse, type: :ref}, 92 | 404 => %{ref: ErrorResponse, type: :ref} 93 | }, 94 | verb: :get 95 | }, 96 | %Route{ 97 | auth: %{JWTAuth: []}, 98 | desc: "It's an action used for multiple routes", 99 | path: "/api/nested/:nested_id/multi", 100 | path_params: %{nested_id: %{required: true, type: :uuid}}, 101 | responses: %{ 102 | 200 => %{ref: UserResponse, type: :ref}, 103 | 404 => %{ref: ErrorResponse, type: :ref} 104 | }, 105 | verb: :get 106 | }, 107 | %Route{ 108 | path: "/api/partials", 109 | path_params: %{ 110 | account_id: %{type: :uuid}, 111 | created_at: %{type: :datetime}, 112 | id: %{desc: "The comment id", type: :uuid}, 113 | mentions: %{of: [%{type: :uuid}], type: :list}, 114 | team_id: %{ 115 | default: 2, 116 | maximum: 10, 117 | minimum: 0, 118 | required: true, 119 | type: :integer 120 | }, 121 | text: %{type: :string} 122 | }, 123 | query_params: %{ 124 | account_id: %{type: :uuid}, 125 | created_at: %{type: :datetime}, 126 | id: %{desc: "The comment id", type: :uuid}, 127 | mentions: %{of: [%{type: :uuid}], type: :list}, 128 | team_id: %{ 129 | default: 2, 130 | maximum: 10, 131 | minimum: 0, 132 | required: true, 133 | type: :integer 134 | }, 135 | text: %{type: :string} 136 | }, 137 | responses: %{200 => %{ref: UserResponse, type: :ref}}, 138 | verb: :get 139 | } 140 | ] 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /test/rolodex/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.SchemaTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.Schema 5 | 6 | alias Rolodex.Mocks.{ 7 | Comment, 8 | NotFound, 9 | Parent, 10 | User, 11 | WithPartials 12 | } 13 | 14 | doctest Schema 15 | 16 | describe "#schema/3 macro" do 17 | test "It generates schema metadata" do 18 | assert User.__schema__(:name) == "User" 19 | assert User.__schema__(:desc) == "A user record" 20 | 21 | assert User.__schema__({nil, :schema}) == %{ 22 | type: :object, 23 | properties: %{ 24 | id: %{type: :uuid, desc: "The id of the user", required: true}, 25 | email: %{type: :string, desc: "The email of the user", required: true}, 26 | comment: %{type: :ref, ref: Comment}, 27 | parent: %{type: :ref, ref: Parent}, 28 | comments: %{type: :list, of: [%{type: :ref, ref: Comment}]}, 29 | short_comments: %{type: :list, of: [%{type: :ref, ref: Comment}]}, 30 | comments_of_many_types: %{ 31 | desc: "List of text or comment", 32 | type: :list, 33 | of: [ 34 | %{type: :string}, 35 | %{type: :ref, ref: Comment} 36 | ] 37 | }, 38 | multi: %{ 39 | type: :one_of, 40 | of: [ 41 | %{type: :string}, 42 | %{type: :ref, ref: NotFound} 43 | ] 44 | }, 45 | private: %{type: :boolean}, 46 | archived: %{type: :boolean}, 47 | active: %{type: :boolean} 48 | } 49 | } 50 | end 51 | end 52 | 53 | describe "#partial/1 macro" do 54 | test "It will collect schema refs, plain keyword lists, or plain maps for merging" do 55 | assert WithPartials.__schema__({nil, :schema}) == %{ 56 | type: :object, 57 | properties: %{ 58 | created_at: %{type: :datetime}, 59 | id: %{type: :uuid, desc: "The comment id"}, 60 | text: %{type: :string}, 61 | mentions: %{type: :list, of: [%{type: :uuid}]} 62 | } 63 | } 64 | end 65 | end 66 | 67 | describe "#to_map/1" do 68 | test "It serializes the schema into a Rolodex.Field struct`" do 69 | assert Schema.to_map(User) == %{ 70 | type: :object, 71 | desc: "A user record", 72 | properties: %{ 73 | id: %{desc: "The id of the user", type: :uuid, required: true}, 74 | email: %{desc: "The email of the user", type: :string, required: true}, 75 | parent: %{type: :ref, ref: Parent}, 76 | comment: %{type: :ref, ref: Comment}, 77 | comments: %{ 78 | type: :list, 79 | of: [%{type: :ref, ref: Comment}] 80 | }, 81 | short_comments: %{ 82 | type: :list, 83 | of: [%{type: :ref, ref: Comment}] 84 | }, 85 | comments_of_many_types: %{ 86 | type: :list, 87 | desc: "List of text or comment", 88 | of: [ 89 | %{type: :string}, 90 | %{type: :ref, ref: Comment} 91 | ] 92 | }, 93 | multi: %{ 94 | type: :one_of, 95 | of: [ 96 | %{type: :string}, 97 | %{type: :ref, ref: NotFound} 98 | ] 99 | }, 100 | private: %{type: :boolean}, 101 | archived: %{type: :boolean}, 102 | active: %{type: :boolean} 103 | } 104 | } 105 | end 106 | 107 | test "It will serialize with partials merged in" do 108 | assert Schema.to_map(WithPartials) == %{ 109 | type: :object, 110 | desc: nil, 111 | properties: %{ 112 | created_at: %{type: :datetime}, 113 | id: %{type: :uuid, desc: "The comment id"}, 114 | text: %{type: :string}, 115 | mentions: %{type: :list, of: [%{type: :uuid}]} 116 | } 117 | } 118 | end 119 | end 120 | 121 | describe "#get_refs/1" do 122 | test "It gets refs within a schema module" do 123 | refs = Schema.get_refs(User) 124 | 125 | assert refs == [Comment, NotFound, Parent] 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /test/rolodex/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.UtilsTest do 2 | use ExUnit.Case 3 | 4 | alias Rolodex.Utils 5 | 6 | describe "#indifferent_find/2" do 7 | test "It will lookup indifferently" do 8 | data = %{} |> Map.put(:foo, :bar) |> Map.put("bar", :baz) 9 | 10 | assert Utils.indifferent_find(data, :foo) == :bar 11 | assert Utils.indifferent_find(data, "foo") == :bar 12 | assert Utils.indifferent_find(data, :bar) == :baz 13 | assert Utils.indifferent_find(data, "bar") == :baz 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/mocks/controllers.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Mocks.TestController do 2 | use Phoenix.Controller, namespace: Rolodex.Mocks 3 | 4 | alias Rolodex.Mocks.{ 5 | UserRequestBody, 6 | UserResponse, 7 | MultiResponse, 8 | PaginatedUsersResponse, 9 | PaginationHeaders, 10 | ErrorResponse, 11 | ParamsSchema 12 | } 13 | 14 | @doc [ 15 | auth: [ 16 | :JWTAuth, 17 | TokenAuth: ["user.read"], 18 | OAuth: ["user.read"] 19 | ], 20 | headers: PaginationHeaders, 21 | query_params: %{ 22 | id: %{ 23 | type: :string, 24 | maximum: 10, 25 | minimum: 0, 26 | required: false, 27 | default: 2 28 | }, 29 | update: :boolean 30 | }, 31 | path_params: %{ 32 | account_id: :uuid 33 | }, 34 | body: UserRequestBody, 35 | responses: %{ 36 | 200 => UserResponse, 37 | 201 => PaginatedUsersResponse, 38 | 404 => ErrorResponse 39 | }, 40 | metadata: %{public: true}, 41 | tags: ["foo", "bar"] 42 | ] 43 | @doc "It's a test!" 44 | def index(_, _), do: nil 45 | 46 | @doc [ 47 | multi: true, 48 | "/api/multi": [ 49 | auth: :JWTAuth, 50 | responses: %{ 51 | 200 => UserResponse, 52 | 201 => MultiResponse, 53 | 404 => ErrorResponse 54 | } 55 | ], 56 | "/api/nested/:nested_id/multi": [ 57 | auth: :JWTAuth, 58 | path_params: [ 59 | nested_id: [type: :uuid, required: true] 60 | ], 61 | responses: %{ 62 | 200 => UserResponse, 63 | 404 => ErrorResponse 64 | } 65 | ] 66 | ] 67 | @doc "It's an action used for multiple routes" 68 | def multi(_, _), do: nil 69 | 70 | @doc [ 71 | multi: true, 72 | get: [ 73 | auth: :JWTAuth, 74 | responses: %{ 75 | 200 => UserResponse, 76 | 201 => MultiResponse, 77 | 404 => ErrorResponse 78 | } 79 | ], 80 | post: [ 81 | auth: :JWTAuth, 82 | path_params: [ 83 | nested_id: [type: :uuid, required: true] 84 | ], 85 | responses: %{ 86 | 200 => UserResponse, 87 | 404 => ErrorResponse 88 | } 89 | ] 90 | ] 91 | @doc "It's an action used for the same path with multiple HTTP actions" 92 | def verb_multi(_, _), do: nil 93 | 94 | @doc [ 95 | auth: :JWTAuth, 96 | headers: %{"X-Request-Id" => :string} 97 | ] 98 | def conflicted(_, _), do: nil 99 | 100 | @doc [ 101 | body: %{id: :uuid}, 102 | headers: %{"X-Request-Id" => %{type: :uuid, required: true}}, 103 | responses: %{ 104 | 200 => %{id: :uuid} 105 | } 106 | ] 107 | def with_bare_maps(_, _), do: nil 108 | 109 | @doc [ 110 | query_params: ParamsSchema, 111 | path_params: ParamsSchema, 112 | responses: %{ 113 | 200 => UserResponse 114 | } 115 | ] 116 | def params_via_schema(_, _), do: nil 117 | 118 | def undocumented(_, _), do: nil 119 | end 120 | -------------------------------------------------------------------------------- /test/support/mocks/headers.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Mocks.PaginationHeaders do 2 | use Rolodex.Headers 3 | 4 | headers "PaginationHeaders" do 5 | field("total", :integer, desc: "Total entries to be retrieved") 6 | 7 | field("per-page", :integer, 8 | desc: "Total entries per page of results", 9 | required: true 10 | ) 11 | end 12 | end 13 | 14 | defmodule Rolodex.Mocks.RateLimitHeaders do 15 | use Rolodex.Headers 16 | 17 | headers "RateLimitHeaders" do 18 | field("limited", :boolean, desc: "Have you been rate limited") 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/mocks/phoenix_routers.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Mocks.TestPhoenixRouter do 2 | use Phoenix.Router 3 | 4 | scope "/api", Rolodex.Mocks do 5 | get("/demo", TestController, :index) 6 | post("/demo/:id", TestController, :conflicted) 7 | put("/demo/:id", TestController, :with_bare_maps) 8 | delete("/demo/:id", TestController, :undocumented) 9 | 10 | # Multi-headed action function 11 | get("/multi", TestController, :multi) 12 | get("/nested/:nested_id/multi", TestController, :multi) 13 | 14 | # This action function uses schemas for query and path params plus partials 15 | get("/partials", TestController, :params_via_schema) 16 | 17 | # This route action does not exist 18 | put("/demo/missing/:id", TestController, :missing_action) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/mocks/request_bodies.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Mocks.UserRequestBody do 2 | use Rolodex.RequestBody 3 | alias Rolodex.Mocks.User 4 | 5 | request_body "UserRequestBody" do 6 | desc("A single user entity request body") 7 | 8 | content "application/json" do 9 | schema(User) 10 | example(:request, %{id: "1"}) 11 | end 12 | end 13 | end 14 | 15 | defmodule Rolodex.Mocks.UsersRequestBody do 16 | use Rolodex.RequestBody 17 | alias Rolodex.Mocks.User 18 | 19 | request_body "UsersRequestBody" do 20 | desc("A list of user entities") 21 | 22 | content "application/json" do 23 | schema([User]) 24 | example(:request, [%{id: "1"}]) 25 | end 26 | end 27 | end 28 | 29 | defmodule Rolodex.Mocks.ParentsRequestBody do 30 | use Rolodex.RequestBody 31 | alias Rolodex.Mocks.Parent 32 | 33 | request_body "ParentsRequestBody" do 34 | desc("A list of parent entities") 35 | 36 | content "application/json" do 37 | schema(:list, of: [Parent]) 38 | end 39 | end 40 | end 41 | 42 | defmodule Rolodex.Mocks.PaginatedUsersRequestBody do 43 | use Rolodex.RequestBody 44 | alias Rolodex.Mocks.User 45 | 46 | request_body "PaginatedUsersRequestBody" do 47 | desc("A paginated list of user entities") 48 | 49 | content "application/json" do 50 | schema(%{ 51 | total: :integer, 52 | page: :integer, 53 | users: [User] 54 | }) 55 | 56 | example(:request, [%{id: "1"}]) 57 | end 58 | end 59 | end 60 | 61 | defmodule Rolodex.Mocks.MultiRequestBody do 62 | use Rolodex.RequestBody 63 | alias Rolodex.Mocks.{Comment, User} 64 | 65 | request_body "MultiRequestBody" do 66 | content "application/json" do 67 | schema(User) 68 | end 69 | 70 | content "application/lolsob" do 71 | schema([Comment]) 72 | end 73 | end 74 | end 75 | 76 | defmodule Rolodex.Mocks.InlineMacroSchemaRequest do 77 | use Rolodex.RequestBody 78 | 79 | alias Rolodex.Mocks.Comment 80 | 81 | request_body "InlineMacroSchemaRequest" do 82 | content "application/json" do 83 | schema do 84 | field(:created_at, :datetime) 85 | 86 | partial(Comment) 87 | partial(mentions: [:uuid]) 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/support/mocks/responses.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Mocks.UserResponse do 2 | use Rolodex.Response 3 | alias Rolodex.Mocks.{User, RateLimitHeaders} 4 | 5 | response "UserResponse" do 6 | desc("A single user entity response") 7 | headers(RateLimitHeaders) 8 | 9 | content "application/json" do 10 | schema(User) 11 | example(:response, %{id: "1"}) 12 | end 13 | end 14 | end 15 | 16 | defmodule Rolodex.Mocks.UsersResponse do 17 | use Rolodex.Response 18 | 19 | alias Rolodex.Mocks.{PaginationHeaders, User} 20 | 21 | response "UsersResponse" do 22 | desc("A list of user entities") 23 | headers(PaginationHeaders) 24 | 25 | content "application/json" do 26 | schema([User]) 27 | example(:response, [%{id: "1"}]) 28 | end 29 | end 30 | end 31 | 32 | defmodule Rolodex.Mocks.ParentsResponse do 33 | use Rolodex.Response 34 | alias Rolodex.Mocks.Parent 35 | 36 | response "ParentsResponse" do 37 | desc("A list of parent entities") 38 | 39 | headers(%{ 40 | "total" => :integer, 41 | "per-page" => %{type: :integer, required: true} 42 | }) 43 | 44 | content "application/json" do 45 | schema(:list, of: [Parent]) 46 | end 47 | end 48 | end 49 | 50 | defmodule Rolodex.Mocks.PaginatedUsersResponse do 51 | use Rolodex.Response 52 | alias Rolodex.Mocks.{PaginationHeaders, User} 53 | 54 | response "PaginatedUsersResponse" do 55 | desc("A paginated list of user entities") 56 | headers(PaginationHeaders) 57 | 58 | content "application/json" do 59 | schema(%{ 60 | total: :integer, 61 | page: :integer, 62 | users: [User] 63 | }) 64 | 65 | example(:response, [%{id: "1"}]) 66 | end 67 | end 68 | end 69 | 70 | defmodule Rolodex.Mocks.ErrorResponse do 71 | use Rolodex.Response 72 | 73 | response "ErrorResponse" do 74 | desc("An error response") 75 | 76 | content "application/json" do 77 | schema(%{ 78 | status: :integer, 79 | message: :string 80 | }) 81 | end 82 | end 83 | end 84 | 85 | defmodule Rolodex.Mocks.MultiResponse do 86 | use Rolodex.Response 87 | 88 | alias Rolodex.Mocks.{ 89 | Comment, 90 | PaginationHeaders, 91 | RateLimitHeaders, 92 | User 93 | } 94 | 95 | response "MultiResponse" do 96 | headers(PaginationHeaders) 97 | headers(RateLimitHeaders) 98 | 99 | content "application/json" do 100 | schema(User) 101 | end 102 | 103 | content "application/lolsob" do 104 | schema([Comment]) 105 | end 106 | end 107 | end 108 | 109 | defmodule Rolodex.Mocks.InlineMacroSchemaResponse do 110 | use Rolodex.Response 111 | 112 | alias Rolodex.Mocks.Comment 113 | 114 | response "InlineMacroSchemaResponse" do 115 | content "application/json" do 116 | schema do 117 | field(:created_at, :datetime) 118 | 119 | partial(Comment) 120 | partial(mentions: [:uuid]) 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /test/support/mocks/routers.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Mocks.TestRouter do 2 | use Rolodex.Router 3 | 4 | router Rolodex.Mocks.TestPhoenixRouter do 5 | route(:get, "/api/demo") 6 | routes([:post, :put, :delete], "/api/demo/:id") 7 | get("/api/multi") 8 | get("/api/nested/:nested_id/multi") 9 | get("/api/partials") 10 | 11 | # This route is defined in the Phoenix router but it has no associated 12 | # controller action 13 | put("/api/demo/missing/:id") 14 | end 15 | end 16 | 17 | defmodule Rolodex.Mocks.MiniTestRouter do 18 | use Rolodex.Router 19 | 20 | router(Rolodex.Mocks.TestPhoenixRouter, do: get("/api/demo")) 21 | end 22 | -------------------------------------------------------------------------------- /test/support/mocks/schemas.ex: -------------------------------------------------------------------------------- 1 | defmodule Rolodex.Mocks.User do 2 | use Rolodex.Schema 3 | 4 | @configs [ 5 | private: :boolean, 6 | archived: :boolean, 7 | active: :boolean 8 | ] 9 | 10 | schema "User", desc: "A user record" do 11 | field(:id, :uuid, desc: "The id of the user", required: true) 12 | field(:email, :string, desc: "The email of the user", required: true) 13 | 14 | # Nested object 15 | field(:comment, Rolodex.Mocks.Comment) 16 | 17 | # Nested schema with a cyclical dependency 18 | field(:parent, Rolodex.Mocks.Parent) 19 | 20 | # List of one type 21 | field(:comments, :list, of: [Rolodex.Mocks.Comment]) 22 | 23 | # Can use the list shorthand 24 | field(:short_comments, [Rolodex.Mocks.Comment]) 25 | 26 | # List of multiple types 27 | field(:comments_of_many_types, :list, 28 | of: [:string, Rolodex.Mocks.Comment], 29 | desc: "List of text or comment" 30 | ) 31 | 32 | # A field with multiple possible types 33 | field(:multi, :one_of, of: [:string, Rolodex.Mocks.NotFound]) 34 | 35 | # Can use a for comprehension to define many fields 36 | for {name, type} <- @configs, do: field(name, type) 37 | end 38 | end 39 | 40 | defmodule Rolodex.Mocks.Parent do 41 | use Rolodex.Schema 42 | 43 | schema "Parent" do 44 | field(:child, Rolodex.Mocks.User) 45 | end 46 | end 47 | 48 | defmodule Rolodex.Mocks.Comment do 49 | use Rolodex.Schema 50 | 51 | schema "Comment", desc: "A comment record" do 52 | field(:id, :uuid, desc: "The comment id") 53 | field(:text, :string) 54 | end 55 | end 56 | 57 | defmodule Rolodex.Mocks.NotFound do 58 | use Rolodex.Schema 59 | 60 | schema "NotFound", desc: "Not found response" do 61 | field(:message, :string) 62 | end 63 | end 64 | 65 | defmodule Rolodex.Mocks.NestedDemo do 66 | use Rolodex.Schema 67 | 68 | schema "NestedDemo" do 69 | field(:nested, Rolodex.Mocks.FirstNested) 70 | end 71 | end 72 | 73 | defmodule Rolodex.Mocks.FirstNested do 74 | use Rolodex.Schema 75 | 76 | schema "FirstNested" do 77 | field(:nested, Rolodex.Mocks.SecondNested) 78 | end 79 | end 80 | 81 | defmodule Rolodex.Mocks.SecondNested do 82 | use Rolodex.Schema 83 | 84 | schema "SecondNested" do 85 | field(:id, :uuid) 86 | end 87 | end 88 | 89 | defmodule Rolodex.Mocks.WithPartials do 90 | use Rolodex.Schema 91 | 92 | schema "WithPartials" do 93 | field(:created_at, :datetime) 94 | 95 | partial(Rolodex.Mocks.Comment) 96 | partial(mentions: [:uuid]) 97 | end 98 | end 99 | 100 | defmodule Rolodex.Mocks.ParamsSchema do 101 | use Rolodex.Schema 102 | 103 | alias Rolodex.Mocks.WithPartials 104 | 105 | schema "ParamsSchema" do 106 | field(:account_id, :uuid) 107 | 108 | field(:team_id, :integer, 109 | maximum: 10, 110 | minimum: 0, 111 | required: true, 112 | default: 2 113 | ) 114 | 115 | partial(WithPartials) 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------