├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE.md ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib └── versionary │ └── plug │ ├── ensure_version.ex │ ├── error_handler.ex │ ├── handler.ex │ ├── phoenix_error_handler.ex │ └── verify_header.ex ├── mix.exs ├── mix.lock └── test ├── test_helper.exs └── versionary └── plug ├── ensure_version_test.exs ├── error_handler_test.exs ├── phoenix_error_handler_test.exs └── verify_header_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: mix 5 | 6 | directory: "/" 7 | 8 | schedule: 9 | interval: daily 10 | 11 | open-pull-requests-limit: 10 12 | 13 | target-branch: master 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: push 4 | 5 | jobs: 6 | dialyzer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - uses: actions/cache@v2 13 | 14 | with: 15 | key: ${{ github.job }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock') }}-0 16 | 17 | path: _build 18 | 19 | - uses: erlef/setup-beam@v1 20 | 21 | with: 22 | elixir-version: ${{ matrix.elixir }} 23 | 24 | otp-version: ${{ matrix.otp }} 25 | 26 | - run: mix deps.get 27 | 28 | - run: mix dialyzer 29 | 30 | strategy: 31 | matrix: 32 | elixir: [1.10.x, 1.11.x, 1.12.x] 33 | 34 | otp: [22.x, 23.x, 24.x] 35 | 36 | test: 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v2 41 | 42 | - uses: actions/cache@v2 43 | 44 | with: 45 | key: ${{ github.job }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock') }}-0 46 | 47 | path: _build 48 | 49 | - uses: erlef/setup-beam@v1 50 | 51 | with: 52 | elixir-version: ${{ matrix.elixir }} 53 | 54 | otp-version: ${{ matrix.otp }} 55 | 56 | - run: mix deps.get 57 | 58 | - run: mix test 59 | 60 | strategy: 61 | matrix: 62 | elixir: [1.10.x, 1.11.x, 1.12.x] 63 | 64 | otp: [22.x, 23.x, 24.x] 65 | -------------------------------------------------------------------------------- /.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 third-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 | versionary-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Ignore macOS directory config. 29 | .DS_Store 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2017 Sticksnleaves 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 | # Versionary 2 | 3 | Add versioning to your Elixir Plug and Phoenix built API's. 4 | 5 | [![CI](https://github.com/sticksnleaves/versionary/actions/workflows/ci.yaml/badge.svg)](https://github.com/sticksnleaves/versionary/actions/workflows/ci.yaml) 6 | [![Coverage Status](https://coveralls.io/repos/github/sticksnleaves/versionary/badge.svg?branch=master)](https://coveralls.io/github/sticksnleaves/versionary?branch=master) 7 | [![Module Version](https://img.shields.io/hexpm/v/versionary.svg)](https://hex.pm/packages/versionary) 8 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/versionary/) 9 | [![Total Download](https://img.shields.io/hexpm/dt/versionary.svg)](https://hex.pm/packages/versionary) 10 | [![License](https://img.shields.io/hexpm/l/versionary.svg)](https://github.com/sticksnleaves/versionary/blob/master/LICENSE.md) 11 | [![Last Updated](https://img.shields.io/github/last-commit/sticksnleaves/versionary.svg)](https://github.com/sticksnleaves/versionary/commits/master) 12 | 13 | 14 | ## Installation 15 | 16 | The package can be installed by adding `:versionary` to your list of dependencies 17 | in `mix.exs`: 18 | 19 | ```elixir 20 | def deps do 21 | [ 22 | {:versionary, "~> 0.3"} 23 | ] 24 | end 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```elixir 30 | def MyAPI.Router do 31 | use Plug.Router 32 | 33 | plug Versionary.Plug.VerifyHeader, versions: ["application/vnd.app.v1+json"] 34 | 35 | plug Versionary.Plug.EnsureVersion, handler: MyAPI.MyErrorHandler 36 | 37 | plug :match 38 | plug :dispatch 39 | end 40 | ``` 41 | 42 | ## MIME Support 43 | 44 | It's possible to verify versions against configured MIME types. If multiple MIME 45 | types are passed and at least one matches the version will be considered valid. 46 | 47 | ```elixir 48 | config :mime, :types, %{ 49 | "application/vnd.app.v1+json" => [:v1], 50 | "application/vnd.app.v2+json" => [:v2] 51 | } 52 | ``` 53 | 54 | ```elixir 55 | plug Versionary.Plug.VerifyHeader, accepts: [:v1, :v2] 56 | ``` 57 | 58 | Please note that whenever you change media type configurations you must 59 | recompile the `mime` library. 60 | 61 | To force `mime` to recompile run `mix deps.clean --build mime`. 62 | 63 | ## Identifying Versions 64 | 65 | When a version has been verified `:version` and `:raw_version` private keys will 66 | be added to the conn. These keys will contain version that has been verified. 67 | 68 | The `:version` key may contain either the string version provided by the 69 | request or, if configured, the MIME extension. The `:raw_version` key will 70 | always contain the string version provided by the request. 71 | 72 | ## Phoenix 73 | 74 | Versionary is just a plug. That means Versionary works with Phoenix out of the 75 | box. However, if you'd like Versionary to render a Phoenix error view when 76 | verification fails use `Versionary.Plug.PhoenixErrorHandler`. 77 | 78 | ```elixir 79 | defmodule MyAPI.Router do 80 | use MyAPI.Web, :router 81 | 82 | pipeline :api do 83 | plug Versionary.Plug.VerifyHeader, accepts: [:v1, :v2] 84 | 85 | plug Versionary.Plug.EnsureVersion, handler: Versionary.Plug.PhoenixErrorHandler 86 | end 87 | 88 | scope "/", MyAPI do 89 | pipe_through :api 90 | 91 | get "/my_controllers", MyController, :index 92 | end 93 | end 94 | ``` 95 | 96 | ### Handling Multiple Versions 97 | 98 | You can pattern match which version of a controller action to run based on the 99 | `:version` (or `:raw_version`) private key provided by the conn. 100 | 101 | ```elixir 102 | defmodule MyAPI.MyController do 103 | use MyAPI, :controller 104 | 105 | def index(%{private: %{version: [:v1]}} = conn, _params) do 106 | render(conn, "index.v1.json", %{}) 107 | end 108 | 109 | def index(%{private: %{version: [:v2]}} = conn, _params) do 110 | render(conn, "index.v2.json", %{}) 111 | end 112 | end 113 | ``` 114 | 115 | ## Plug API 116 | 117 | ### [Versionary.Plug.VerifyHeader](https://hexdocs.pm/versionary/Versionary.Plug.VerifyHeader.html) 118 | 119 | Verify that the version passed in to the request as a header is valid. If the 120 | version is not valid then the request will be flagged. 121 | 122 | This plug will not handle an invalid version. If you would like to halt the 123 | request and handle an invalid version please see 124 | [`Versionary.Plug.EnsureVersion`](https://hexdocs.pm/versionary/Versionary.Plug.EnsureVersion.html). 125 | 126 | #### Options 127 | 128 | `accepts` - a list of strings or atoms representing versions registered as 129 | MIME types. If at least one of the registered versions is valid then the 130 | request is considered valid. 131 | 132 | `versions` - a list of strings representing valid versions. If at least one of 133 | the provided versions is valid then the request is considered valid. 134 | 135 | `header` - the header used to provide the requested version (Default: `Accept`) 136 | 137 | ### [Versionary.Plug.EnsureVersion](https://hexdocs.pm/versionary/Versionary.Plug.EnsureVersion.html) 138 | 139 | Checks to see if the request has been flagged with a valid version. If the 140 | version is valid, the request continues, otherwise the request will halt and the 141 | handler will be called to process the request. 142 | 143 | #### Options 144 | 145 | `handler` - the module used to handle a request with an invalid version 146 | (Default: [Versionary.Plug.ErrorHandler](https://hexdocs.pm/versionary/Versionary.Plug.ErrorHandler.html)) 147 | 148 | ### [Versionary.Plug.Handler](https://hexdocs.pm/versionary/Versionary.Plug.Handler.html) 149 | 150 | Behaviour for handling requests with invalid versions. You can create your own 151 | custom handler with this behaviour. 152 | 153 | # Copyright and License 154 | 155 | Copyright (c) 2017 Sticksnleaves 156 | 157 | This library is licensed under the [MIT license](./LICENSE.md). 158 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | import_config "#{Mix.env}.exs" 6 | -------------------------------------------------------------------------------- /config/dev.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 | -------------------------------------------------------------------------------- /config/prod.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 | -------------------------------------------------------------------------------- /config/test.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 :mime, :types, %{ 6 | "application/vnd.app.v1+json" => [:v1], 7 | "application/vnd.app.v2+json" => [:v2] 8 | } 9 | -------------------------------------------------------------------------------- /lib/versionary/plug/ensure_version.ex: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Plug.EnsureVersion do 2 | @moduledoc """ 3 | This plug ensures that a valid version was provided and has been verified 4 | on the request. 5 | 6 | If the version provided is not valid then the request will be halted and the 7 | module provided to `handler` will be called. From there the handler can decide 8 | how to finish the request. 9 | 10 | If a handler isn't provided `Versionary.Plug.ErrorHandler.call/1` will be used 11 | as a default. 12 | 13 | ## Example 14 | 15 | ```elixir 16 | plug Versionary.Plug.EnsureVersion, handler: SomeModule 17 | ``` 18 | 19 | """ 20 | 21 | require Logger 22 | 23 | import Plug.Conn 24 | 25 | @doc false 26 | def init(opts \\ []) do 27 | %{ 28 | handler: opts[:handler] || Versionary.Plug.ErrorHandler 29 | } 30 | end 31 | 32 | @doc false 33 | def call(conn, opts) do 34 | case conn.private[:version_verified] do 35 | true -> 36 | conn 37 | false -> 38 | handle_error(conn, opts) 39 | nil -> 40 | Logger.warn("Version has not been verified. Make sure Versionary.Plug.VerifyHeader has been called.") 41 | conn 42 | end 43 | end 44 | 45 | # private 46 | 47 | defp handle_error(conn, opts) do 48 | handler_opt = opts[:handler] 49 | 50 | conn = conn |> halt 51 | 52 | apply(handler_opt, :call, [conn]) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/versionary/plug/error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Plug.ErrorHandler do 2 | @moduledoc """ 3 | A default error handler that can be used for failed version verification. 4 | 5 | When called this handler will respond to the request with a 6 | `406 Not Acceptable` HTTP status. 7 | """ 8 | 9 | @behaviour Versionary.Plug.Handler 10 | 11 | import Plug.Conn 12 | 13 | def call(conn) do 14 | conn 15 | |> send_resp(406, "Not Acceptable") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/versionary/plug/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Plug.Handler do 2 | @moduledoc """ 3 | Provides the ability to specify how to finish processing a request when 4 | version validation fails. 5 | 6 | Typically a handler will finish the request by responding with a 400 level 7 | HTTP status code. For example, `Versionary.Plug.ErrorHandler` responds to the 8 | request with a `406` status code and sets the body to `Not Acceptable`. 9 | 10 | To create your own handler override the `call/1` function of this behavior. 11 | A `Plug.Conn` will be provided as the only argument. 12 | 13 | Keep in mind that `Versionary.Plug.EnsureVersion` will halt the request 14 | before calling a handler. Once a handler processes the error the plug request 15 | lifecycle will finish. 16 | 17 | ## Example 18 | 19 | ```elixir 20 | defmodule MyAPI.MyErrorHandler do 21 | @behaviour Versionary.Plug.Handler 22 | 23 | def call(conn) do 24 | body = %{ 25 | error: %{ 26 | description: "Not Acceptable" 27 | } 28 | } 29 | 30 | conn 31 | |> send_resp(406, Poison.encode!(body)) 32 | end 33 | end 34 | 35 | defmodule MyAPI.MyController do 36 | plug Versionary.Plug.VerifyHeader, versions: ["application/vnd.app.v1+json"] 37 | 38 | plug Versionary.Plug.EnsureVersion, handler: MyAPI.MyErrorHandler 39 | end 40 | ``` 41 | """ 42 | 43 | @callback call(Plug.Conn.t) :: Plug.Conn.t 44 | end 45 | -------------------------------------------------------------------------------- /lib/versionary/plug/phoenix_error_handler.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Phoenix) do 2 | defmodule Versionary.Plug.PhoenixErrorHandler do 3 | @moduledoc """ 4 | An error handler for usage with Phoenix. 5 | 6 | When called this handler raise a `Phoenix.NotAcceptableError` triggering the 7 | `406.json` error view. 8 | """ 9 | 10 | @behaviour Versionary.Plug.Handler 11 | 12 | def call(_conn) do 13 | raise Phoenix.NotAcceptableError, 14 | message: "no supported media type in accept header" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/versionary/plug/verify_header.ex: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Plug.VerifyHeader do 2 | @moduledoc """ 3 | Use this plug to verify a version string in the header. 4 | 5 | This plug will add a `:version_verified` private key to the conn. This value 6 | will be `true` if the version has been verified. Otherwise, it will be 7 | `false`. 8 | 9 | Note that this plug will only flag the conn as having a valid or invalid 10 | version. If you would like to halt the request and handle an invalid version 11 | please see `Versionary.Plug.EnsureVersion`. 12 | 13 | ## Options 14 | 15 | * `:versions` - a list of strings representing valid versions. If at least 16 | one of the provided versions is valid then the request is considered valid. 17 | 18 | * `:accepts` - a list of strings or atoms representing versions registered as 19 | MIME types. If at least one of the registered versions is valid then the 20 | request is considered valid. 21 | 22 | * `:header` - the header used to provide the requested version (Default: 23 | `Accept`) 24 | 25 | ## Example 26 | 27 | plug Versionary.Plug.VerifyHeader, versions: ["application/vnd.app.v1+json"] 28 | 29 | ## Multiple Versions 30 | 31 | You may pass multiple version strings to the `:versions` option. If at least 32 | one version matches the request will be considered valid. 33 | 34 | ```elixir 35 | plug Versionary.Plug.VerifyHeader, versions: ["application/vnd.app.v1+json", 36 | "application/vnd.app.v2+json"] 37 | ``` 38 | 39 | ## MIME Support 40 | 41 | It's also possible to verify versions against configured MIME types. If 42 | multiple MIME types are passed and at least one matches the version will be 43 | considered valid. 44 | 45 | ```elixir 46 | config :mime, :types, %{ 47 | "application/vnd.app.v1+json" => [:v1] 48 | } 49 | ``` 50 | 51 | ```elixir 52 | plug Versionary.Plug.VerifyHeader, accepts: [:v1] 53 | ``` 54 | 55 | Please note that whenever you change media type configurations you must 56 | recompile the `mime` library. 57 | 58 | To force `mime` to recompile run `mix deps.clean --build mime`. 59 | 60 | ## Identifying Versions 61 | 62 | When a version has been verified this plug will add `:version` and 63 | `:raw_version` private keys to the conn. These keys will contain version that 64 | has been verified. 65 | 66 | The `:version` key may contain either the string version provided by the 67 | request or, if configured, the MIME extension. The `:raw_version` key will 68 | always contain the string version provided by the request. 69 | """ 70 | 71 | import Plug.Conn 72 | 73 | @default_header_opt "accept" 74 | 75 | @doc false 76 | def init(opts) do 77 | %{ 78 | accepts: Keyword.get(opts, :accepts, []), 79 | header: Keyword.get(opts, :header, @default_header_opt), 80 | versions: Keyword.get(opts, :versions, []) 81 | } 82 | end 83 | 84 | @doc false 85 | def call(conn, opts) do 86 | conn 87 | |> verify_version(opts) 88 | |> put_version(opts) 89 | end 90 | 91 | # 92 | # private 93 | # 94 | 95 | defp verify_version(conn, opts) do 96 | verified = Enum.member?(get_valid_versions(opts), get_req_version(conn, opts)) 97 | put_private(conn, :version_verified, verified) 98 | end 99 | 100 | defp put_version(%{private: %{version_verified: true}} = conn, opts) do 101 | raw_version = get_req_version(conn, opts) 102 | version = Map.get(MIME.compiled_custom_types(), raw_version, raw_version) 103 | 104 | conn 105 | |> put_private(:version, version) 106 | |> put_private(:raw_version, raw_version) 107 | end 108 | 109 | defp put_version(conn, _opts) do 110 | conn 111 | end 112 | 113 | # 114 | # helpers 115 | # 116 | 117 | defp get_valid_versions(opts) do 118 | opts[:versions] ++ get_mime_versions(opts) 119 | end 120 | 121 | defp get_mime_versions(%{accepts: accepts}), do: do_get_mime_versions(accepts) 122 | defp get_mime_versions(_opts), do: [] 123 | 124 | defp do_get_mime_versions([h | t]), do: [MIME.type(h)] ++ do_get_mime_versions(t) 125 | defp do_get_mime_versions([]), do: [] 126 | defp do_get_mime_versions(nil), do: [] 127 | 128 | defp get_req_version(%Plug.Conn{} = conn, %{header: header} = opts) do 129 | get_req_header(conn, header) 130 | |> List.first() 131 | |> get_req_version(opts) 132 | end 133 | defp get_req_version([], _opts), do: false 134 | defp get_req_version(nil, _opts), do: nil 135 | defp get_req_version(headers, opts) when is_binary(headers) do 136 | String.split(headers, ",") 137 | |> Enum.map(&String.split(&1, ";") |> hd) 138 | |> Enum.map(&String.trim/1) 139 | |> get_req_version(opts) 140 | end 141 | defp get_req_version([head | tail], opts) do 142 | case Enum.member?(get_valid_versions(opts), head) do 143 | true -> head 144 | false -> get_req_version(tail, opts) 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/sticksnleaves/versionary" 5 | @version "0.4.1" 6 | 7 | def project do 8 | [ 9 | app: :versionary, 10 | name: "Versionary", 11 | version: @version, 12 | elixir: "~> 1.9", 13 | build_embedded: Mix.env() == :prod, 14 | start_permanent: Mix.env() == :prod, 15 | deps: deps(), 16 | dialyzer: [plt_add_apps: [:mime, :phoenix, :plug]], 17 | docs: docs(), 18 | package: package(), 19 | preferred_cli_env: [ 20 | coveralls: :test, 21 | "coveralls.detail": :test, 22 | "coveralls.html": :test, 23 | "coveralls.post": :test, 24 | "coveralls.travis": :test 25 | ], 26 | test_coverage: [tool: ExCoveralls] 27 | ] 28 | end 29 | 30 | def application do 31 | [extra_applications: [:logger]] 32 | end 33 | 34 | defp deps do 35 | [ 36 | {:mime, "~>1.0 or ~> 2.0"}, 37 | {:plug, "~> 1.3"}, 38 | # dev 39 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 40 | # test 41 | {:excoveralls, "~> 0.11", only: :test, runtime: false}, 42 | {:phoenix, ">= 1.2.0", only: :test}, 43 | # dev/test 44 | {:dialyxir, "~> 1.1.0", only: [:dev, :test], runtime: false} 45 | ] 46 | end 47 | 48 | defp package do 49 | [ 50 | description: "Elixir plug for handling API versioning", 51 | maintainers: ["Anthony Smith"], 52 | licenses: ["MIT"], 53 | links: %{ 54 | GitHub: @source_url 55 | } 56 | ] 57 | end 58 | 59 | defp docs do 60 | [ 61 | extras: [ 62 | "LICENSE.md": [title: "License"], 63 | "README.md": [title: "Overview"] 64 | ], 65 | main: "readme", 66 | source_url: @source_url, 67 | source_ref: "#v{@version}", 68 | formatters: ["html"] 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, 3 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 4 | "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm", "762b999fd414fb41e297944228aa1de2cd4a3876a07f968c8b11d1e9a2190d07"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"}, 6 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 7 | "ex_doc": {:hex, :ex_doc, "0.25.3", "3edf6a0d70a39d2eafde030b8895501b1c93692effcbd21347296c18e47618ce", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "9ebebc2169ec732a38e9e779fd0418c9189b3ca93f4a676c961be6c1527913f5"}, 8 | "excoveralls": {:hex, :excoveralls, "0.14.2", "f9f5fd0004d7bbeaa28ea9606251bb643c313c3d60710bad1f5809c845b748f0", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ca6fd358621cb4d29311b29d4732c4d47dac70e622850979bc54ed9a3e50f3e1"}, 9 | "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, 10 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 11 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 12 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 15 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 16 | "mime": {:hex, :mime, "2.0.1", "0de4c81303fe07806ebc2494d5321ce8fb4df106e34dd5f9d787b637ebadc256", [:mix], [], "hexpm", "7a86b920d2aedce5fb6280ac8261ac1a739ae6c1a1ad38f5eadf910063008942"}, 17 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 18 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 19 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 20 | "phoenix": {:hex, :phoenix, "1.5.13", "d4e0805ec0973bed80d67302631130fb47d75b1a0b7335a0b23c4432b6ce55ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1a7c4f1900e6e60bb60ae6680e48418e3f7c360d58bcb9f812487b6d0d281a0f"}, 21 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 22 | "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, 23 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 25 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 26 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 27 | } 28 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _apps} = Application.ensure_all_started(:plug) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /test/versionary/plug/ensure_version_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Plug.EnsureVersionTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | import ExUnit.CaptureLog 6 | 7 | alias Versionary.Plug.EnsureVersion 8 | 9 | defmodule TestHandler do 10 | @moduledoc false 11 | 12 | def call(conn) do 13 | conn 14 | |> Plug.Conn.assign(:versionary_spec, :not_supported) 15 | |> Plug.Conn.send_resp(406, "Not Supported") 16 | end 17 | end 18 | 19 | @opts EnsureVersion.init([handler: TestHandler]) 20 | 21 | test "init/1 sets the handler option to the module that's passed in" do 22 | assert @opts[:handler] == TestHandler 23 | end 24 | 25 | test "init/1 sets the default handler if a value is not passed in" do 26 | opts = EnsureVersion.init() 27 | 28 | assert opts[:handler] == Versionary.Plug.ErrorHandler 29 | end 30 | 31 | test "request does not halt if version is verified" do 32 | conn = 33 | conn(:get, "/") 34 | |> put_private(:version_verified, true) 35 | |> EnsureVersion.call(@opts) 36 | 37 | refute conn.halted 38 | end 39 | 40 | test "request does not halt of verification has not happened" do 41 | conn = 42 | conn(:get, "/") 43 | |> EnsureVersion.call(@opts) 44 | 45 | refute conn.halted 46 | end 47 | 48 | test "warning is logged if verification has not happened" do 49 | assert capture_log([level: :warn], fn -> 50 | conn(:get, "/") |> EnsureVersion.call(@opts) 51 | end) =~ "Version has not been verified." 52 | end 53 | 54 | test "request does halt if version is not verified" do 55 | conn = 56 | conn(:get, "/") 57 | |> put_private(:version_verified, false) 58 | |> EnsureVersion.call(@opts) 59 | 60 | assert conn.halted 61 | end 62 | 63 | test "handler is called when version is not verified" do 64 | conn = 65 | conn(:get, "/") 66 | |> put_private(:version_verified, false) 67 | |> EnsureVersion.call(@opts) 68 | 69 | assert conn.assigns[:versionary_spec] == :not_supported 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/versionary/plug/error_handler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Plug.ErrorHandlerTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | alias Versionary.Plug.ErrorHandler 6 | 7 | test "respond with a status of 406" do 8 | conn = 9 | conn(:get, "/") 10 | |> ErrorHandler.call 11 | 12 | assert conn.status == 406 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/versionary/plug/phoenix_error_handler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Plug.PhoenixErrorHandlerTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | alias Versionary.Plug.PhoenixErrorHandler 6 | 7 | test "respond with a status of 406" do 8 | assert_raise(Phoenix.NotAcceptableError, fn() -> 9 | conn(:get, "/") 10 | |> PhoenixErrorHandler.call 11 | end) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/versionary/plug/verify_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versionary.Plug.VerifyHeaderTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | alias Versionary.Plug.VerifyHeader 6 | 7 | @v1 "application/vnd.app.v1+json" 8 | @v2 "application/vnd.app.v2+json" 9 | @v3 "application/vnd.app.v3+json" 10 | @mixed "application/vnd.app.v1+json,application/json;q=0.9" 11 | 12 | @opts1 VerifyHeader.init(versions: [@v1]) 13 | @opts2 VerifyHeader.init(header: "x-version", versions: [@v1]) 14 | @opts3 VerifyHeader.init(versions: [@v1, @v2]) 15 | @opts4 VerifyHeader.init(accepts: [:v1, :v2]) 16 | 17 | test "init/1 sets the header option to the value passed in" do 18 | assert @opts2[:header] == "x-version" 19 | end 20 | 21 | test "init/1 sets the default header if a value is not passed in" do 22 | assert @opts1[:header] == "accept" 23 | end 24 | 25 | test "init/1 sets the versions option to the value passed in" do 26 | assert @opts1[:versions] == [@v1] 27 | end 28 | 29 | test "verification fails if version is not present" do 30 | conn = VerifyHeader.call(conn(:get, "/"), @opts1) 31 | 32 | assert conn.private[:version_verified] == false 33 | end 34 | 35 | test "verification fails if version is incorrect" do 36 | conn = 37 | conn(:get, "/") 38 | |> put_req_header("accept", @v2) 39 | |> VerifyHeader.call(@opts1) 40 | 41 | assert conn.private[:version_verified] == false 42 | end 43 | 44 | test "verification fails if mime is incorrect" do 45 | conn = 46 | conn(:get, "/") 47 | |> put_req_header("accept", @v3) 48 | |> VerifyHeader.call(@opts4) 49 | 50 | assert conn.private[:version_verified] == false 51 | end 52 | 53 | test "verification fails if header is incorrect" do 54 | conn = 55 | conn(:get, "/") 56 | |> put_req_header("accept", @v1) 57 | |> VerifyHeader.call(@opts2) 58 | 59 | assert conn.private[:version_verified] == false 60 | end 61 | 62 | test "does not store version if verification fails" do 63 | conn = 64 | conn(:get, "/") 65 | |> put_req_header("accept", @v1) 66 | |> VerifyHeader.call(@opts2) 67 | 68 | assert conn.private[:version] == nil 69 | end 70 | 71 | test "does not store raw version if verification fails" do 72 | conn = 73 | conn(:get, "/") 74 | |> put_req_header("accept", @v1) 75 | |> VerifyHeader.call(@opts2) 76 | 77 | assert conn.private[:raw_version] == nil 78 | end 79 | 80 | test "verification succeeds if version matches" do 81 | conn = 82 | conn(:get, "/") 83 | |> put_req_header("accept", @v1) 84 | |> VerifyHeader.call(@opts1) 85 | 86 | assert conn.private[:version_verified] == true 87 | end 88 | 89 | test "verification succeeds if header and version match" do 90 | conn = 91 | conn(:get, "/") 92 | |> put_req_header("x-version", @v1) 93 | |> VerifyHeader.call(@opts2) 94 | 95 | assert conn.private[:version_verified] == true 96 | end 97 | 98 | test "verification succeeds if at least one version matches" do 99 | conn = 100 | conn(:get, "/") 101 | |> put_req_header("accept", @v1) 102 | |> VerifyHeader.call(@opts3) 103 | 104 | assert conn.private[:version_verified] == true 105 | end 106 | 107 | test "store used version if verification succeeds" do 108 | conn = 109 | conn(:get, "/") 110 | |> put_req_header("accept", @v1) 111 | |> VerifyHeader.call(@opts3) 112 | 113 | assert conn.private[:version] == [:v1] 114 | end 115 | 116 | test "store used raw version if verification succeeds" do 117 | conn = 118 | conn(:get, "/") 119 | |> put_req_header("accept", @v1) 120 | |> VerifyHeader.call(@opts3) 121 | 122 | assert conn.private[:raw_version] == @v1 123 | end 124 | 125 | test "verification succeeds if at least one mime matches" do 126 | conn = 127 | conn(:get, "/") 128 | |> put_req_header("accept", @v1) 129 | |> VerifyHeader.call(@opts4) 130 | 131 | assert conn.private[:version_verified] == true 132 | end 133 | 134 | test "verification succeeds if at least one mime matches, accept header v2" do 135 | conn = 136 | conn(:get, "/") 137 | |> put_req_header("accept", @v2) 138 | |> VerifyHeader.call(@opts4) 139 | 140 | assert conn.private[:version_verified] == true 141 | end 142 | 143 | test "verification succeeds when using mixed accept headers, and at least one mime matches" do 144 | conn = 145 | conn(:get, "/") 146 | |> put_req_header("accept", @mixed) 147 | |> VerifyHeader.call(@opts4) 148 | 149 | assert conn.private[:version_verified] == true 150 | end 151 | 152 | test "verification succeeds when using mixed x-version headers, and at least one mime matches" do 153 | conn = 154 | conn(:get, "/") 155 | |> put_req_header("x-version", @mixed) 156 | |> VerifyHeader.call(@opts2) 157 | 158 | assert conn.private[:version_verified] == true 159 | end 160 | 161 | end 162 | --------------------------------------------------------------------------------