├── .formatter.exs ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── maru_swagger.ex └── maru_swagger │ ├── config_struct.ex │ ├── dsl.ex │ ├── params_extractor.ex │ ├── plug.ex │ └── response_formatter.ex ├── mix.exs ├── mix.lock └── test ├── config_struct_test.exs ├── maru_swagger_plug_test.exs ├── maru_versioning_test.exs ├── params_extractor_test.exs ├── response_formatter_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: [ 4 | ".formatter.exs", 5 | "mix.exs", 6 | "{config,lib,test}/**/*.{ex,exs}" 7 | ], 8 | import_deps: [:maru], 9 | locals_without_parens: [ 10 | swagger: 1 11 | ] 12 | ] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /doc 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | matrix: 4 | include: 5 | - otp_release: 19.3 6 | elixir: 1.4.5 7 | - otp_release: 20.3 8 | elixir: 1.4.5 9 | 10 | - otp_release: 19.3 11 | elixir: 1.5.3 12 | - otp_release: 20.3 13 | elixir: 1.5.3 14 | 15 | - otp_release: 19.3 16 | elixir: 1.6.6 17 | - otp_release: 20.3 18 | elixir: 1.6.6 19 | 20 | - otp_release: 21.3 21 | elixir: 1.7.4 22 | 23 | - otp_release: 21.3 24 | elixir: 1.8.1 25 | 26 | sudo: false 27 | notifications: 28 | recipients: 29 | - self@falood.me 30 | before_script: 31 | - mix deps.get --only test 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ## v0.8.5 (2018-4-19) 4 | * Enhancements 5 | * upgrade to maru v0.13.0 6 | 7 | ## v0.8.4 (2018-1-2) 8 | * Bugfix 9 | * validation in parameters test 10 | 11 | ## v0.8.3 (2017-10-17) 12 | * Bugfix 13 | * show paramter in path as query 14 | * Deprecations 15 | * no longer support elixir 1.3 16 | 17 | ## v0.8.2 (2017-01-06) 18 | * Enhancements 19 | * add force_json option 20 | 21 | ## v0.8.1 (2016-11-23) 22 | * Enhancements 23 | * use buildin UnitTest 24 | * support response status codes and desc 25 | * support one line list parameter 26 | * support dependent parameter 27 | 28 | ## v0.8.0 (2016-6-11) 29 | * Enhancements 30 | * update to maru-0.10.0 31 | * support nested struct for body params 32 | * use formData by default for no nested struct 33 | 34 | ## v0.7.3 (2016-3-11) 35 | * Enhancements 36 | * Some code cleanup + some more unit tests 37 | * support change parameter name by source option 38 | 39 | ## v0.7.2 (2016-2-19) 40 | * Enhancements 41 | * unit tests with ExSpec + small matcher macro 42 | * wording for error message in case Maru was not configured in config.ex 43 | * 2 new modules: ResponseFormatter + ParamsExtractor 44 | * travis config 45 | * Readme badges 46 | 47 | * Bugfix 48 | * required params on GET endpoints 49 | 50 | ## v0.7.1 (2016-2-19) 51 | * Bugfix 52 | * body params missing 53 | * ignore Validation in params list 54 | 55 | ## v0.7.0 (2016-1-29) 56 | * Enhancements 57 | * Support multiple version 58 | * Support maru router forwarded by Phoenix 59 | 60 | ## v0.6.0 (2015-11-29) 61 | * Enhancements 62 | * Support Maru 0.9 63 | 64 | ## v0.5.0 (2015-9-21) 65 | * Enhancements 66 | * Support Maru 0.8 67 | * Support Maru Version 68 | * Support pretty format 69 | 70 | ## v0.4.0 (2015-9-14) 71 | * Enhancements 72 | * Support Maru 0.7 73 | 74 | ## v0.3.0 (2015-8-3) 75 | * Enhancements 76 | * Support Maru 0.5 77 | 78 | ## v0.2.1 (2015-7-20) 79 | * Enhancements 80 | * Support Maru new version mechanism 81 | 82 | ## v0.2.0 (2015-7-11) 83 | * Enhancements 84 | * Support Maru 0.4 85 | 86 | ## v0.1.0 (2015-6-2) 87 | * Enhancements 88 | * Support Maru v0.3.0 89 | * Add default response(code: 200, description: ok) 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Falood 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MaruSwagger 2 | =========== 3 | 4 | [![Build status](https://img.shields.io/travis/elixir-maru/maru_swagger.svg?style=flat-square)](https://travis-ci.org/elixir-maru/maru_swagger) 5 | [![hex.pm Version](https://img.shields.io/hexpm/v/maru_swagger.svg?style=flat-square)](https://hex.pm/packages/maru_swagger) 6 | [![Hex downloads](https://img.shields.io/hexpm/dt/maru_swagger.svg?style=flat-square)](https://hex.pm/packages/maru_swagger) 7 | 8 | ## Usage 9 | 10 | GOTCHA: Please keep `swagger` DSL out of `version`! 11 | 12 | ```elixir 13 | def deps do 14 | [ {:maru_swagger, github: "elixir-maru/maru_swagger"} ] 15 | end 16 | 17 | defmodule Router do 18 | version "v1" 19 | ... 20 | end 21 | 22 | defmodule API do 23 | use Maru.Router 24 | use MaruSwagger 25 | 26 | plug Plug.Logger 27 | 28 | swagger at: "/swagger", # (required) the mount point for the URL 29 | pretty: true, # (optional) should JSON be pretty-printed? 30 | only: [:dev], # (optional) the environments swagger works 31 | except: [:prod], # (optional) the environments swagger NOT works 32 | force_json: true, # (optional) force JSON for all params instead of formData 33 | 34 | swagger_inject: [ # (optional) this will be directly injected into the root Swagger JSON 35 | host: "myapi.com", 36 | basePath: "/api", 37 | schemes: [ "http" ], 38 | consumes: [ "application/json" ], 39 | produces: [ 40 | "application/json", 41 | "application/vnd.api+json" 42 | ] 43 | ] 44 | 45 | mount Router 46 | end 47 | ``` 48 | 49 | and then you can get json response from `curl http://127.0.0.1:4000/swagger`. 50 | 51 | open [Swagger Petstore](http://petstore.swagger.io) in your borwser and fill in `http://127.0.0.1:4000/swagger` and enjoy maru_swagger. 52 | 53 | 54 | ## Thanks 55 | 56 | * [Cifer](https://github.com/Cifer-Y) 57 | * [Roman Heinrich](https://github.com/mindreframer) 58 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /lib/maru_swagger.ex: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger do 2 | defmacro __using__(_) do 3 | quote do 4 | import MaruSwagger.DSL 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/maru_swagger/config_struct.ex: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.ConfigStruct do 2 | defstruct [ 3 | # [string] where to mount the Swagger JSON 4 | :path, 5 | # [atom] Maru API module 6 | :module, 7 | # [boolean] force JSON for all params instead of formData 8 | :force_json, 9 | # [boolean] should JSON output be prettified? 10 | :pretty, 11 | # [keyword list] key-values to inject directly into root of Swagger JSON 12 | :swagger_inject, 13 | # [keyword list] key-values to inject directly into info of Swagger JSON 14 | :info 15 | ] 16 | 17 | def from_opts(opts) do 18 | path = opts |> Keyword.fetch!(:at) |> Maru.Utils.split_path() 19 | module = opts |> Keyword.fetch!(:module) 20 | force_json = opts |> Keyword.get(:force_json, false) 21 | pretty = opts |> Keyword.get(:pretty, false) 22 | 23 | swagger_inject = 24 | opts 25 | |> Keyword.get(:swagger_inject, []) 26 | |> Keyword.put_new_lazy(:basePath, base_path_func(module)) 27 | |> check_swagger_inject_keys 28 | 29 | info = opts |> Keyword.get(:info, []) |> check_info_inject_keys 30 | 31 | %__MODULE__{ 32 | path: path, 33 | module: module, 34 | force_json: force_json, 35 | pretty: pretty, 36 | swagger_inject: swagger_inject, 37 | info: info 38 | } 39 | end 40 | 41 | defp base_path_func(module) do 42 | fn -> 43 | [ 44 | "" 45 | | if apply(Code, :ensure_loaded?, [Phoenix]) do 46 | phoenix_module = 47 | Mix.Phoenix 48 | |> apply(:base, []) 49 | |> Module.concat("Router") 50 | 51 | phoenix_module.__routes__ 52 | |> Enum.filter(fn r -> 53 | match?(%{kind: :forward, plug: ^module}, r) 54 | end) 55 | |> case do 56 | [%{path: p}] -> p |> String.split("/", trim: true) 57 | _ -> [] 58 | end 59 | else 60 | [] 61 | end 62 | ] 63 | |> Enum.join("/") 64 | end 65 | end 66 | 67 | defp check_swagger_inject_keys(swagger_inject) do 68 | swagger_inject 69 | |> Enum.filter(fn {k, v} -> 70 | k in allowed_swagger_fields() and not (v in [nil, ""]) 71 | end) 72 | end 73 | 74 | defp allowed_swagger_fields do 75 | [:host, :basePath, :schemes, :consumes, :produces, :securityDefinitions, :security] 76 | end 77 | 78 | defp check_info_inject_keys(info) do 79 | info 80 | |> Enum.filter(fn {k, v} -> 81 | k in allowed_info_fields() and not (v in [nil, ""]) 82 | end) 83 | end 84 | 85 | defp allowed_info_fields do 86 | [:title, :desc] 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/maru_swagger/dsl.ex: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.DSL do 2 | defmacro swagger(options) do 3 | only = options |> Keyword.get(:only) 4 | except = options |> Keyword.get(:except) 5 | 6 | guard = 7 | case {only, except} do 8 | {nil, nil} -> true 9 | {nil, _} -> not (Mix.env() in except) 10 | {_, nil} -> Mix.env() in only 11 | _ -> raise ":only and :except are in conflict!" 12 | end 13 | 14 | quote do 15 | if unquote(guard) do 16 | @plugs_before { 17 | MaruSwagger.Plug, 18 | unquote(options) 19 | |> Keyword.drop([:only, :except]) 20 | |> Keyword.put(:module, __MODULE__), 21 | true 22 | } 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/maru_swagger/params_extractor.ex: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.ParamsExtractor do 2 | alias Maru.Builder.Parameter.Information, as: PI 3 | alias Maru.Builder.Parameter.Dependent.Information, as: DI 4 | 5 | defmodule NonGetBodyParamsGenerator do 6 | def generate(param_list, path) do 7 | {path_param_list, body_param_list} = 8 | param_list |> MaruSwagger.ParamsExtractor.filter_information() 9 | |> Enum.split_with(&(&1.attr_name in path)) 10 | 11 | [ 12 | format_body_params(body_param_list) 13 | | format_path_params(path_param_list) 14 | ] 15 | end 16 | 17 | defp default_body do 18 | %{name: "body", in: "body", description: "", required: false} 19 | end 20 | 21 | defp format_path_params(param_list) do 22 | Enum.map(param_list, fn param -> 23 | %{ 24 | name: param.param_key, 25 | description: param.desc || "", 26 | type: param.type, 27 | required: param.required, 28 | in: "path" 29 | } 30 | end) 31 | end 32 | 33 | defp format_body_params(param_list) do 34 | param_list 35 | |> Enum.map(&format_param/1) 36 | |> case do 37 | [] -> 38 | default_body() 39 | 40 | params -> 41 | params = Enum.into(params, %{}) 42 | 43 | default_body() 44 | |> put_in([:schema], %{}) 45 | |> put_in([:schema, :properties], params) 46 | end 47 | end 48 | 49 | defp format_param(param) do 50 | {param.param_key, do_format_param(param.type, param)} 51 | end 52 | 53 | defp do_format_param("float", param) do 54 | %{type: "number", format: "float", description: param.desc || "", required: param.required} 55 | end 56 | 57 | defp do_format_param("map", param) do 58 | %{type: "object", properties: param.children |> Enum.map(&format_param/1) |> Enum.into(%{})} 59 | end 60 | 61 | defp do_format_param("list", param) do 62 | %{ 63 | type: "array", 64 | items: %{ 65 | type: "object", 66 | properties: param.children |> Enum.map(&format_param/1) |> Enum.into(%{}) 67 | } 68 | } 69 | end 70 | 71 | defp do_format_param({:list, type}, param) do 72 | %{type: "array", items: do_format_param(type, param)} 73 | end 74 | 75 | defp do_format_param(type, param) do 76 | %{description: param.desc || "", type: type, required: param.required} 77 | end 78 | end 79 | 80 | defmodule NonGetFormDataParamsGenerator do 81 | def generate(param_list, path) do 82 | param_list 83 | |> MaruSwagger.ParamsExtractor.filter_information() 84 | |> Enum.map(fn param -> 85 | %{ 86 | name: param.param_key, 87 | description: param.desc || "", 88 | type: param.type, 89 | required: param.required, 90 | in: (param.attr_name in path && "path") || "formData" 91 | } 92 | end) 93 | end 94 | end 95 | 96 | alias Maru.Router 97 | 98 | def extract_params(%Router{method: :get, path: path, parameters: parameters}, _config) do 99 | for %PI{} = param <- parameters do 100 | %{ 101 | name: param.param_key, 102 | description: param.desc || "", 103 | required: param.required, 104 | type: param.type, 105 | in: (param.attr_name in path && "path") || "query" 106 | } 107 | end 108 | end 109 | 110 | def extract_params(%Router{method: :get}, _config), do: [] 111 | def extract_params(%Router{parameters: []}, _config), do: [] 112 | 113 | def extract_params(%Router{parameters: param_list, path: path}, config) do 114 | param_list = filter_information(param_list) 115 | 116 | generator = 117 | if config.force_json do 118 | NonGetBodyParamsGenerator 119 | else 120 | case judge_adapter(param_list) do 121 | :body -> NonGetBodyParamsGenerator 122 | :form_data -> NonGetFormDataParamsGenerator 123 | end 124 | end 125 | 126 | generator.generate(param_list, path) 127 | end 128 | 129 | defp judge_adapter([]), do: :form_data 130 | defp judge_adapter([%{type: "list"} | _]), do: :body 131 | defp judge_adapter([%{type: "map"} | _]), do: :body 132 | defp judge_adapter([%{type: {:list, _}} | _]), do: :body 133 | defp judge_adapter([_ | t]), do: judge_adapter(t) 134 | 135 | def filter_information(param_list) do 136 | Enum.filter(param_list, fn 137 | %PI{} -> true 138 | %DI{} -> true 139 | _ -> false 140 | end) 141 | |> flatten_dependents 142 | end 143 | 144 | def flatten_dependents(param_list, force_optional \\ false) do 145 | Enum.reduce(param_list, [], fn 146 | %PI{} = i, acc when force_optional -> 147 | do_append(acc, %{i | required: false}) 148 | 149 | %PI{} = i, acc -> 150 | do_append(acc, i) 151 | 152 | %DI{children: children}, acc -> 153 | flatten_dependents(children, true) 154 | |> Enum.reduce(acc, fn i, deps -> 155 | do_append(deps, i) 156 | end) 157 | end) 158 | end 159 | 160 | defp do_append(param_list, i) do 161 | Enum.any?(param_list, fn param -> 162 | param.param_key == i.param_key 163 | end) 164 | |> case do 165 | true -> param_list 166 | false -> param_list ++ [i] 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/maru_swagger/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.Plug do 2 | use Maru.Middleware 3 | alias MaruSwagger.ConfigStruct 4 | alias Plug.Conn 5 | 6 | def init(opts) do 7 | ConfigStruct.from_opts(opts) 8 | end 9 | 10 | def call(%Conn{path_info: path} = conn, %ConfigStruct{path: path} = config) do 11 | json_library = Maru.json_library() 12 | resp = config |> generate() |> json_library.encode!(pretty: config.pretty) 13 | 14 | conn 15 | |> Conn.put_resp_header("access-control-allow-origin", "*") 16 | |> Conn.put_resp_content_type("application/json") 17 | |> Conn.send_resp(200, resp) 18 | |> Conn.halt() 19 | end 20 | 21 | def call(conn, _) do 22 | conn |> Conn.put_resp_header("access-control-allow-origin", "*") 23 | end 24 | 25 | def generate(%ConfigStruct{} = config) do 26 | c = (Application.get_env(:maru, config.module) || [])[:versioning] || [] 27 | adapter = Maru.Builder.Versioning.get_adapter(c[:using]) 28 | 29 | routes = 30 | config.module.__routes__ 31 | |> Enum.map(fn route -> 32 | parameters = Enum.map(route.parameters, & &1.information) 33 | %{route | parameters: parameters} 34 | end) 35 | 36 | tags = 37 | routes 38 | |> Enum.map(& &1.version) 39 | |> Enum.uniq() 40 | |> Enum.map(fn v -> %{name: tag_name(v)} end) 41 | 42 | routes = 43 | routes 44 | |> Enum.map(&extract_route(&1, adapter, config)) 45 | 46 | MaruSwagger.ResponseFormatter.format(routes, tags, config) 47 | end 48 | 49 | defp extract_route(ep, adapter, config) do 50 | params = MaruSwagger.ParamsExtractor.extract_params(ep, config) 51 | path = adapter.path_for_params(ep.path, ep.version) 52 | 53 | method = 54 | case ep.method do 55 | {:_, [], nil} -> :match 56 | m -> m 57 | end 58 | 59 | %{ 60 | desc: ep.desc, 61 | method: method, 62 | path: path, 63 | params: params, 64 | tag: tag_name(ep.version) 65 | } 66 | end 67 | 68 | defp tag_name(nil), do: "DEFAULT" 69 | defp tag_name(v), do: "Version: #{v}" 70 | end 71 | -------------------------------------------------------------------------------- /lib/maru_swagger/response_formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.ResponseFormatter do 2 | alias MaruSwagger.ConfigStruct 3 | 4 | def format(routes, tags, config = %ConfigStruct{}) do 5 | paths = 6 | routes 7 | |> List.foldr(%{}, fn %{ 8 | desc: desc, 9 | method: method, 10 | path: url_list, 11 | params: params, 12 | tag: tag 13 | }, 14 | result -> 15 | desc = desc || %{} 16 | responses = desc[:responses] || [%{code: 200, description: "ok"}] 17 | url = join_path(url_list) 18 | 19 | if Map.has_key?(result, url) do 20 | result 21 | else 22 | result |> put_in([url], %{}) 23 | end 24 | |> put_in([url, to_string(method)], %{ 25 | tags: [tag], 26 | description: desc[:detail] || "", 27 | summary: desc[:summary] || "", 28 | parameters: params, 29 | responses: 30 | for r <- responses, into: %{} do 31 | {to_string(r.code), %{description: r.description}} 32 | end 33 | }) 34 | end) 35 | 36 | wrap_in_swagger_info(paths, tags, config) 37 | end 38 | 39 | defp wrap_in_swagger_info(paths, tags, config = %ConfigStruct{}) do 40 | res = %{ 41 | swagger: "2.0", 42 | info: 43 | case config.info do 44 | [_ | _] -> format_info(config.info) 45 | _ -> format_default(config) 46 | end, 47 | paths: paths, 48 | tags: tags 49 | } 50 | 51 | for {k, v} <- config.swagger_inject || [], into: res, do: {k, v} 52 | end 53 | 54 | defp format_info(info) do 55 | %{ 56 | title: info[:title], 57 | description: info[:desc] 58 | } 59 | end 60 | 61 | defp format_default(config) do 62 | %{title: "Swagger API for #{elixir_module_name(config.module)}"} 63 | end 64 | 65 | defp elixir_module_name(module) do 66 | "Elixir." <> m = module |> to_string 67 | m 68 | end 69 | 70 | defp join_path(path) do 71 | [ 72 | "/" 73 | | for i <- path do 74 | cond do 75 | is_atom(i) -> "{#{i}}" 76 | is_binary(i) -> i 77 | true -> raise "unknow path type" 78 | end 79 | end 80 | ] 81 | |> Path.join() 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :maru_swagger, 7 | version: "0.8.6", 8 | elixir: "~> 1.4", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | description: "Add swagger compliant documentation to your maru API", 13 | source_url: "https://github.com/elixir-maru/maru_swagger", 14 | package: package(), 15 | docs: [ 16 | extras: ["README.md"], 17 | main: "readme" 18 | ] 19 | ] 20 | end 21 | 22 | def application do 23 | [] 24 | end 25 | 26 | defp deps do 27 | [ 28 | {:maru, "~> 0.13 or ~> 0.14"}, 29 | {:jason, "~> 1.0", optional: true}, 30 | {:cowboy, "~> 2.1", optional: true}, 31 | {:ex_doc, "~> 0.19", only: :docs} 32 | ] 33 | end 34 | 35 | defp package do 36 | %{ 37 | maintainers: ["Xiangrong Hao", "Roman Heinrich", "Cifer"], 38 | licenses: ["BSD 3-Clause"], 39 | links: %{"Github" => "https://github.com/elixir-maru/maru_swagger"} 40 | } 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"}, 4 | "earmark": {:hex, :earmark, "1.3.0", "17f0c38eaafb4800f746b457313af4b2442a8c2405b49c645768680f900be603", [:mix], [], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "0.5.6", "da47b331b1fe0a5f0380cc3a6967200eac5e1daaa9c6bff4b0310b3fcc12b98f", [:mix], [{:nimble_parsec, "~> 0.4.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "maru": {:hex, :maru, "0.13.2", "f35d58730e642239bdf0e4acec19c4606ef99999a4f3f4e99ebdc7f92b2515d1", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 11 | "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm"}, 12 | "plug": {:hex, :plug, "1.7.1", "8516d565fb84a6a8b2ca722e74e2cd25ca0fc9d64f364ec9dbec09d33eb78ccd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 14 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [], [], "hexpm"}, 15 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, 16 | } 17 | -------------------------------------------------------------------------------- /test/config_struct_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.ConfigStructTest do 2 | use ExUnit.Case, async: true 3 | doctest MaruSwagger.ConfigStruct 4 | alias MaruSwagger.ConfigStruct 5 | 6 | describe "MaruSwagger - Plug: init options" do 7 | defmodule BasicTest.Api do 8 | use Maru.Router 9 | end 10 | 11 | def init(opts) do 12 | [{:module, nil} | opts] |> ConfigStruct.from_opts() 13 | end 14 | 15 | def api_module do 16 | MaruSwagger.ConfigStructTest.BasicTest.Api 17 | end 18 | 19 | test "requires :at for mounting point" do 20 | assert %MaruSwagger.ConfigStruct{ 21 | path: ["swagger", "v1"], 22 | pretty: false, 23 | swagger_inject: [] 24 | } = init(at: "swagger/v1") 25 | end 26 | 27 | test "raises without :at" do 28 | assert_raise KeyError, 29 | "key :at not found in: [module: nil, for: MaruSwagger.ConfigStructTest.BasicTest.Api]", 30 | fn -> 31 | init(for: BasicTest.Api) 32 | end 33 | end 34 | 35 | test "accepts :pretty for JSON output" do 36 | assert %MaruSwagger.ConfigStruct{ 37 | path: ["swagger", "v1"], 38 | pretty: true, 39 | swagger_inject: [] 40 | } = 41 | init( 42 | at: "swagger/v1", 43 | pretty: true 44 | ) 45 | end 46 | 47 | test "accepts :prefix to prepend to URLs" do 48 | assert %MaruSwagger.ConfigStruct{ 49 | path: ["swagger", "v1"], 50 | pretty: true, 51 | swagger_inject: [] 52 | } = 53 | init( 54 | at: "swagger/v1", 55 | pretty: true 56 | ) 57 | end 58 | end 59 | 60 | describe "swagger_inject" do 61 | @only_valid_fields [ 62 | host: "myapi.com", 63 | schemes: ["http"], 64 | consumes: ["application/json"], 65 | produces: ["application/json", "application/vnd.api+json"] 66 | ] 67 | 68 | @some_invalid_fields [ 69 | host: "myapi.com", 70 | invalidbasePath: "/", 71 | schemes: ["http"], 72 | consumes: ["application/json"], 73 | produces: ["application/json", "application/vnd.api+json"] 74 | ] 75 | test "only allowes pre-defined fields" do 76 | res = 77 | init( 78 | at: "swagger/v1", 79 | swagger_inject: @only_valid_fields 80 | ) 81 | 82 | assert res.swagger_inject == @only_valid_fields 83 | end 84 | 85 | test "filters non-predefined fields" do 86 | res = 87 | init( 88 | at: "swagger/v1", 89 | swagger_inject: @some_invalid_fields 90 | ) 91 | 92 | refute res.swagger_inject == @some_invalid_fields 93 | 94 | assert res.swagger_inject == [ 95 | host: "myapi.com", 96 | schemes: ["http"], 97 | consumes: ["application/json"], 98 | produces: ["application/json", "application/vnd.api+json"] 99 | ] 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/maru_swagger_plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.PlugTest do 2 | use ExUnit.Case, async: true 3 | doctest MaruSwagger 4 | import TestHelper 5 | alias MaruSwagger.ConfigStruct 6 | 7 | describe "basic test" do 8 | defmodule BasicTest.Homepage do 9 | use Maru.Router 10 | 11 | desc "hello world action" 12 | 13 | params do 14 | requires :id, type: Integer 15 | end 16 | 17 | get "/" do 18 | _ = params 19 | conn |> json(%{hello: :world}) 20 | end 21 | end 22 | 23 | defmodule BasicTest.Api do 24 | use Maru.Router 25 | 26 | mount MaruSwagger.PlugTest.BasicTest.Homepage 27 | end 28 | 29 | test "includes the required params" do 30 | %ConfigStruct{module: MaruSwagger.PlugTest.BasicTest.Api} 31 | |> MaruSwagger.Plug.generate() 32 | |> assert_route_info("/", %{ 33 | "get" => %{ 34 | summary: "hello world action", 35 | description: "", 36 | parameters: [ 37 | %{description: "", in: "query", name: "id", required: true, type: "integer"} 38 | ], 39 | responses: %{"200" => %{description: "ok"}}, 40 | tags: ["DEFAULT"] 41 | } 42 | }) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/maru_versioning_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MaruVersioningTest do 2 | use ExUnit.Case, async: true 3 | doctest MaruSwagger 4 | alias MaruSwagger.ConfigStruct 5 | 6 | describe "basic test" do 7 | defmodule BasicTest.Homepage do 8 | use Maru.Router 9 | 10 | desc "basic get" do 11 | detail "detail of basic get" 12 | 13 | responses do 14 | status :default, desc: "ok" 15 | status 500, desc: "error" 16 | end 17 | end 18 | 19 | params do 20 | requires :id, type: Integer 21 | end 22 | 23 | get "/basic" do 24 | conn |> json(%{id: params.id}) 25 | end 26 | end 27 | 28 | defmodule BasicTest.Api do 29 | use Maru.Router 30 | 31 | version "v1" do 32 | get "/bla" do 33 | conn |> json(%{}) 34 | end 35 | 36 | mount MaruVersioningTest.BasicTest.Homepage 37 | end 38 | end 39 | 40 | test "includes the API version" do 41 | swagger_docs = 42 | %ConfigStruct{ 43 | module: MaruVersioningTest.BasicTest.Api 44 | } 45 | |> MaruSwagger.Plug.generate() 46 | 47 | assert swagger_docs.tags == [%{name: "Version: v1"}] 48 | end 49 | 50 | test "includes the paths information" do 51 | swagger_docs = 52 | %ConfigStruct{ 53 | module: MaruVersioningTest.BasicTest.Api 54 | } 55 | |> MaruSwagger.Plug.generate() 56 | 57 | assert %{ 58 | "/basic" => %{ 59 | "get" => %{ 60 | summary: "basic get", 61 | description: "detail of basic get", 62 | parameters: [ 63 | %{description: "", in: "query", name: "id", required: true, type: "integer"} 64 | ], 65 | responses: %{ 66 | "default" => %{description: "ok"}, 67 | "500" => %{description: "error"} 68 | }, 69 | tags: ["Version: v1"] 70 | } 71 | }, 72 | "/bla" => %{ 73 | "get" => %{ 74 | summary: "", 75 | description: "", 76 | parameters: [], 77 | responses: %{"200" => %{description: "ok"}}, 78 | tags: ["Version: v1"] 79 | } 80 | } 81 | } = swagger_docs.paths 82 | end 83 | end 84 | 85 | describe "with different versions" do 86 | defmodule DiffVersions.Homepage do 87 | use Maru.Router 88 | 89 | version "v2" do 90 | desc "basic get" 91 | 92 | params do 93 | requires :id, type: Integer 94 | end 95 | 96 | get "/basic" do 97 | conn |> json(%{id: params.id}) 98 | end 99 | end 100 | end 101 | 102 | defmodule DiffVersions.Api do 103 | use Maru.Router 104 | 105 | version "v1" do 106 | get "/bla" do 107 | conn |> json(%{}) 108 | end 109 | end 110 | 111 | mount MaruVersioningTest.DiffVersions.Homepage 112 | end 113 | 114 | test "returns only docs for specified version" do 115 | swagger_docs = 116 | %ConfigStruct{ 117 | module: MaruVersioningTest.DiffVersions.Api 118 | } 119 | |> MaruSwagger.Plug.generate() 120 | 121 | assert swagger_docs.tags == [%{name: "Version: v1"}, %{name: "Version: v2"}] 122 | 123 | assert %{ 124 | "/bla" => %{ 125 | "get" => %{ 126 | tags: ["Version: v1"] 127 | } 128 | }, 129 | "/basic" => %{ 130 | "get" => %{ 131 | tags: ["Version: v2"] 132 | } 133 | } 134 | } = swagger_docs.paths 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/params_extractor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.ParamsExtractorTest do 2 | use ExUnit.Case, async: true 3 | doctest MaruSwagger.ParamsExtractor 4 | import TestHelper 5 | 6 | describe "POST" do 7 | defmodule BasicPostApi do 8 | use Maru.Router 9 | desc "res1 create" 10 | 11 | params do 12 | requires :name, type: :string, source: "user_name" 13 | requires :email, type: :string 14 | end 15 | 16 | post "/res1" do 17 | conn |> json(params) 18 | end 19 | 20 | desc "parameter in path" 21 | 22 | params do 23 | requires :foo, type: Integer 24 | end 25 | 26 | post "/:foo" do 27 | conn |> json(params) 28 | end 29 | end 30 | 31 | test "works with basic POST params" do 32 | route_info = route_from_module(BasicPostApi, :post, ["res1"]) 33 | 34 | assert [ 35 | %{ 36 | description: "", 37 | in: "formData", 38 | name: "user_name", 39 | required: true, 40 | type: "string" 41 | }, 42 | %{description: "", in: "formData", name: "email", required: true, type: "string"} 43 | ] == extract_params(route_info) 44 | end 45 | 46 | test "force json" do 47 | route_info = route_from_module(BasicPostApi, :post, ["res1"]) 48 | 49 | assert [ 50 | %{ 51 | description: "", 52 | in: "body", 53 | name: "body", 54 | required: false, 55 | schema: %{ 56 | properties: %{ 57 | "email" => %{description: "", required: true, type: "string"}, 58 | "user_name" => %{description: "", required: true, type: "string"} 59 | } 60 | } 61 | } 62 | ] == extract_params(route_info, %{force_json: true}) 63 | end 64 | 65 | test "basic POST params in path" do 66 | route_info = route_from_module(BasicPostApi, :post, ["123"]) 67 | 68 | assert [ 69 | %{description: "", in: "path", name: "foo", required: true, type: "integer"} 70 | ] == extract_params(route_info) 71 | end 72 | end 73 | 74 | describe "more extensive POST example" do 75 | defmodule BasicTest.Homepage do 76 | use Maru.Router 77 | desc "root page" 78 | 79 | params do 80 | requires :id, type: Integer 81 | 82 | optional :query, type: List do 83 | optional :keyword, type: String 84 | end 85 | end 86 | 87 | post "/list" do 88 | _ = params 89 | conn |> json(%{hello: :world}) 90 | end 91 | 92 | desc "complex post" 93 | 94 | params do 95 | requires :name, type: :map do 96 | requires :first, type: :string 97 | requires :last, type: :string 98 | end 99 | 100 | requires :email, type: :string 101 | optional :age, type: :integer, desc: "age information" 102 | end 103 | 104 | post "/map" do 105 | conn |> json(params) 106 | end 107 | 108 | desc "dependent params" 109 | 110 | params do 111 | requires :foo, type: Integer 112 | 113 | given foo: fn val -> val > 10 end do 114 | optional :bar 115 | 116 | given :bar do 117 | requires :qux 118 | end 119 | end 120 | 121 | given foo: fn val -> val < 10 end do 122 | requires :baz 123 | end 124 | end 125 | 126 | post "/dependent" do 127 | conn |> json(params) 128 | end 129 | end 130 | 131 | defmodule BasicTest.Api do 132 | use Maru.Router 133 | mount MaruSwagger.ParamsExtractorTest.BasicTest.Homepage 134 | end 135 | 136 | test "extracts expected swagger data from nested list params" do 137 | route_info = route_from_module(BasicTest.Homepage, :post, ["list"]) 138 | 139 | assert [ 140 | %{ 141 | description: "", 142 | in: "body", 143 | name: "body", 144 | required: false, 145 | schema: %{ 146 | properties: %{ 147 | "id" => %{description: "", required: true, type: "integer"}, 148 | "query" => %{ 149 | items: %{ 150 | properties: %{ 151 | "keyword" => %{description: "", required: false, type: "string"} 152 | }, 153 | type: "object" 154 | }, 155 | type: "array" 156 | } 157 | } 158 | } 159 | } 160 | ] = extract_params(route_info) 161 | end 162 | 163 | test "extracts expected swagger data from nested map params" do 164 | route_info = route_from_module(BasicTest.Homepage, :post, ["map"]) 165 | 166 | assert [ 167 | %{ 168 | description: "", 169 | in: "body", 170 | name: "body", 171 | required: false, 172 | schema: %{ 173 | properties: %{ 174 | "age" => %{description: "age information", required: false, type: "integer"}, 175 | "email" => %{description: "", required: true, type: "string"}, 176 | "name" => %{ 177 | type: "object", 178 | properties: %{ 179 | "first" => %{description: "", required: true, type: "string"}, 180 | "last" => %{description: "", required: true, type: "string"} 181 | } 182 | } 183 | } 184 | } 185 | } 186 | ] = extract_params(route_info) 187 | end 188 | 189 | test "dependent params" do 190 | route_info = route_from_module(BasicTest.Homepage, :post, ["dependent"]) 191 | 192 | assert [ 193 | %{name: "foo", required: true, type: "integer"}, 194 | %{name: "bar", required: false, type: "string"}, 195 | %{name: "qux", required: false, type: "string"}, 196 | %{name: "baz", required: false, type: "string"} 197 | ] = extract_params(route_info) 198 | end 199 | end 200 | 201 | describe "one-line nested list test" do 202 | defmodule OneLineNestedList do 203 | use Maru.Router 204 | 205 | desc "one-line nested list test" 206 | 207 | params do 208 | requires :foo, type: List[String] 209 | end 210 | 211 | post "/path" do 212 | conn |> json(params) 213 | end 214 | end 215 | 216 | test "one-line nested list test" do 217 | route_info = route_from_module(OneLineNestedList, :post, ["path"]) 218 | 219 | assert [ 220 | %{ 221 | description: "", 222 | in: "body", 223 | name: "body", 224 | required: false, 225 | schema: %{ 226 | properties: %{ 227 | "foo" => %{ 228 | type: "array", 229 | items: %{type: "string"} 230 | } 231 | } 232 | } 233 | } 234 | ] = extract_params(route_info) 235 | end 236 | end 237 | 238 | describe "validation in parameters test" do 239 | defmodule ValidationInParametes do 240 | use Maru.Router 241 | 242 | desc "validation in parameters list test" 243 | 244 | params do 245 | requires :foo 246 | requires :bar 247 | all_or_none_of [:foo, :bar] 248 | end 249 | 250 | get "/path" do 251 | conn |> json(params) 252 | end 253 | end 254 | 255 | test "validation in parameters test" do 256 | route_info = route_from_module(ValidationInParametes, :get, ["path"]) 257 | assert [%{name: "foo"}, %{name: "bar"}] = extract_params(route_info) 258 | end 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /test/response_formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MaruSwagger.ResponseFormatterTest do 2 | use ExUnit.Case, async: true 3 | doctest MaruSwagger.ResponseFormatter 4 | alias MaruSwagger.ConfigStruct 5 | import Plug.Test 6 | 7 | describe "basic test" do 8 | def get_response(module, conn) do 9 | json_library = Maru.json_library() 10 | res = module.call(conn, []) 11 | {:ok, json} = res.resp_body |> json_library.decode(keys: :atoms) 12 | json 13 | end 14 | 15 | defmodule BasicTest.Homepage do 16 | use Maru.Router 17 | version "v1" 18 | 19 | desc "hello world action" 20 | 21 | params do 22 | requires :id, type: Integer 23 | end 24 | 25 | get "/" do 26 | _ = params 27 | conn |> json(%{hello: :world}) 28 | end 29 | 30 | desc "creates res1" 31 | 32 | params do 33 | requires :name, type: String 34 | requires :email, type: String 35 | end 36 | 37 | post "/res1" do 38 | conn |> json(params) 39 | end 40 | end 41 | 42 | defmodule BasicTest.API do 43 | use Maru.Router, make_plug: true 44 | use MaruSwagger 45 | 46 | # (required) the mount point for the URL 47 | swagger at: "/swagger/v1.json", 48 | # (optional) should JSON be pretty-printed? 49 | pretty: true, 50 | # (optional) this will be directly injected into the root Swagger JSON 51 | swagger_inject: [ 52 | host: "myapi.com", 53 | basePath: "/api", 54 | schemes: ["http"], 55 | consumes: ["application/json"], 56 | produces: [ 57 | "application/json", 58 | "application/vnd.api+json" 59 | ] 60 | ] 61 | 62 | mount MaruSwagger.ResponseFormatterTest.BasicTest.Homepage 63 | end 64 | 65 | test "includes basic information for swagger (title, API version, Swagger version)" do 66 | swagger_docs = 67 | %ConfigStruct{ 68 | module: MaruSwagger.ResponseFormatterTest.BasicTest.Homepage 69 | } 70 | |> MaruSwagger.Plug.generate() 71 | 72 | assert swagger_docs |> get_in([:info, :title]) == 73 | "Swagger API for MaruSwagger.ResponseFormatterTest.BasicTest.Homepage" 74 | 75 | assert swagger_docs |> get_in([:swagger]) == "2.0" 76 | end 77 | 78 | test "works in full integration" do 79 | json = get_response(BasicTest.API, conn(:get, "/swagger/v1.json")) 80 | assert json.basePath == "/api" 81 | assert json.host == "myapi.com" 82 | end 83 | 84 | test "swagger info config" do 85 | swagger_docs = 86 | %ConfigStruct{ 87 | module: MaruSwagger.ResponseFormatterTest.BasicTest.Homepage, 88 | info: [title: "title", desc: "description"] 89 | } 90 | |> MaruSwagger.Plug.generate() 91 | 92 | assert swagger_docs |> get_in([:info, :title]) == "title" 93 | assert swagger_docs |> get_in([:info, :description]) == "description" 94 | assert swagger_docs |> get_in([:swagger]) == "2.0" 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule TestHelper do 4 | defmacro assert_route_info(swagger_docs, path, expected) do 5 | quote do 6 | real_info = unquote(swagger_docs) |> get_in([:paths, unquote(path)]) 7 | assert real_info == unquote(expected) 8 | end 9 | end 10 | 11 | @doc """ 12 | helper function to get Endpoint datastructure from a given module 13 | 14 | Example: 15 | # post "/complex", no version -> 16 | TestHelper.route_from_module(BasicTest.Homepage, :post, ["complex"]) 17 | 18 | # post "/complex", "v1" -> 19 | # TestHelper.route_from_module(BasicTest.Homepage, "v1", :post, ["complex"]) 20 | """ 21 | def route_from_module(module, version \\ nil, method, path_list) do 22 | route = 23 | Enum.find(module.__routes__, fn x -> 24 | path_match?(path_list, x.path) && x.method == method && x.version == version 25 | end) 26 | 27 | parameters = Enum.map(route.parameters, & &1.information) 28 | %{route | parameters: parameters} 29 | end 30 | 31 | @doc """ 32 | shortcut to extract_params 33 | """ 34 | def extract_params(route, config \\ %{force_json: false}) do 35 | MaruSwagger.ParamsExtractor.extract_params(route, config) 36 | end 37 | 38 | defp path_match?([], []), do: true 39 | defp path_match?([h | t1], [h | t2]), do: path_match?(t1, t2) 40 | defp path_match?([_ | t1], [h | t2]) when is_atom(h), do: path_match?(t1, t2) 41 | defp path_match?(_, _), do: false 42 | end 43 | --------------------------------------------------------------------------------