├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── config └── config.exs ├── lib └── plug_require_header.ex ├── mix.exs ├── mix.lock └── test ├── plug_require_header_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | /doc 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.1.1 4 | otp_release: 5 | - 18.1 6 | after_script: 7 | - mix deps.get --only docs 8 | - MIX_ENV=docs mix inch.report 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Lennart Fridén 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlugRequireHeader 2 | 3 | **NB:** This repository has been moved to [https://codeberg.org/DevL/plug_require_header](https://codeberg.org/DevL/plug_require_header). 4 | 5 | [![Build Status](https://travis-ci.org/DevL/plug_require_header.svg?branch=master)](https://travis-ci.org/DevL/plug_require_header) 6 | [![Inline docs](http://inch-ci.org/github/DevL/plug_require_header.svg?branch=master)](http://inch-ci.org/github/DevL/plug_require_header) 7 | [![Hex.pm](https://img.shields.io/hexpm/v/plug_require_header.svg)](https://hex.pm/packages/plug_require_header) 8 | [![Documentation](https://img.shields.io/badge/Documentation-online-c800c8.svg)](http://hexdocs.pm/plug_require_header) 9 | 10 | An Elixir Plug for requiring and extracting a given header. 11 | 12 | ## Usage 13 | 14 | Update your `mix.exs` file and run `mix deps.get`. 15 | ```elixir 16 | defp deps do 17 | [{:plug_require_header, "~> 0.8"}] 18 | end 19 | ``` 20 | 21 | Add the plug to e.g. a pipeline in a [Phoenix](http://www.phoenixframework.org/) 22 | controller. In this case we will require the request header `x-api-key` to be set, 23 | extract its first value and assign it the connection (a `Plug.Conn`) for later use 24 | in another plug or action. 25 | ```elixir 26 | defmodule MyPhoenixApp.MyController do 27 | use MyPhoenixApp.Web, :controller 28 | alias Plug.Conn.Status 29 | 30 | plug PlugRequireHeader, headers: [api_key: "x-api-key"] 31 | plug :action 32 | 33 | def index(conn, _params) do 34 | conn 35 | |> put_status(Status.code :ok) 36 | |> text "The API key used is: #{conn.assigns[:api_key]}" 37 | end 38 | end 39 | ``` 40 | Notice how the first value required header `"x-api-key"` has been extracted 41 | and can be retrieved using `conn.assigns[:api_key]`. An alternative is to use 42 | `Plug.Conn.get_req_header/2` to get all the values associated with a given header. 43 | 44 | By default, a missing header will return a status code of 403 (forbidden) and halt 45 | the plug pipeline, i.e. no subsequent plugs will be executed. The same is true if 46 | the required header is explicitly set to `nil` as the underlying HTTP server will 47 | not include the header. This behaviour however is configurable. 48 | ```elixir 49 | defmodule MyPhoenixApp.MyOtherController do 50 | use MyPhoenixApp.Web, :controller 51 | alias Plug.Conn.Status 52 | 53 | plug PlugRequireHeader, headers: [api_key: "x-api-key"], 54 | on_missing: [status: 418, message: %{error: "I'm a teapot!"}, as: :json] 55 | plug :action 56 | 57 | def index(conn, _params) do 58 | conn 59 | |> put_status(Status.code :ok) 60 | |> text "The API key used is: #{conn.assigns[:api_key]}" 61 | end 62 | ``` 63 | The `:on_missing` handling can be given a keyword list of options on how to handle 64 | a missing header. 65 | 66 | * `:status` - an `integer` or `atom` to specify the status code. If it's an atom, 67 | it'll be looked up using the `Plug.Status.code` function. Default is `:forbidden`. 68 | * `:message` - a `binary` sent as the response body. Default is an empty string. 69 | * `:as` - an `atom` describing the content type and encoding. Currently supported 70 | alternatives are `:text` for plain text and `:json` for JSON. Default is `:text`. 71 | 72 | You can also provide a function that handles the missing header by specifying a 73 | module/function pair in a tuple as the `:on_missing` value. 74 | ```elixir 75 | defmodule MyPhoenixApp.MyOtherController do 76 | use MyPhoenixApp.Web, :controller 77 | alias Plug.Conn.Status 78 | 79 | plug PlugRequireHeader, headers: [api_key: "x-api-key"], 80 | on_missing: {__MODULE__, :handle_missing_header} 81 | plug :action 82 | 83 | def index(conn, _params) do 84 | conn 85 | |> put_status(Status.code :ok) 86 | |> text("The API key used is: #{conn.assigns[:api_key]}") 87 | end 88 | 89 | def handle_missing_header(conn, {_connection_assignment_key, missing_header_key}) do 90 | conn 91 | |> send_resp(Status.code(:bad_request), "Missing header: #{missing_header_key}") 92 | |> halt 93 | end 94 | end 95 | ``` 96 | If the header is missing or set to `nil` the status code, a status code of 400 97 | (bad request) will be returned before the plug pipeline is halted. Notice that 98 | the function specified as a callback needs to be a public function as it'll be 99 | invoked from another module. Also notice that the callback must return a `Plug.Conn` struct. 100 | 101 | Lastly, it's possible to extract multiple headers at the same time. 102 | ```elixir 103 | plug PlugRequireHeader, headers: [api_key: "x-api-key", magic: "x-magic"] 104 | ``` 105 | 106 | If extracting multiple headers _and_ specifying an `:on_missing` callback, be aware 107 | that the callback will be invoked once for each missing header. Be careful to not send 108 | a response as you can easily run into raising a `Plug.Conn.AlreadySentError`. A way of 109 | avoiding this is to have your callback function pattern match on the state of the `conn`. 110 | ```elixir 111 | plug PlugRequireHeader, headers: [api_key: "x-api-key", secret: "x-secret"], 112 | on_missing: {__MODULE__, :handle_missing_header} 113 | 114 | def handle_missing_header(%Plug.Conn{state: :sent} = conn, _), do: conn 115 | def handle_missing_header(conn, {_connection_assignment_key, missing_header_key}) do 116 | conn 117 | |> send_resp(Status.code(:bad_request), "Missing header: #{missing_header_key}") 118 | |> halt 119 | end 120 | ``` 121 | This example will only send a response for the first missing header. 122 | 123 | ## Online documentation 124 | 125 | For more information, see [the full documentation](http://hexdocs.pm/plug_require_header). 126 | 127 | ## Contributing 128 | 129 | 1. Fork this repository 130 | 2. Create your feature branch (`git checkout -b I-say-we-take-off-and-nuke-it-from-orbit`) 131 | 3. Commit your changes (`git commit -am 'It is the only way to be sure!'`) 132 | 4. Push to the branch (`git push origin I-say-we-take-off-and-nuke-it-from-orbit`) 133 | 5. Create a new Pull Request 134 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | -------------------------------------------------------------------------------- /lib/plug_require_header.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugRequireHeader do 2 | import Plug.Conn 3 | alias Plug.Conn.Status 4 | 5 | @vsn "0.8.1" 6 | @doc false 7 | def version, do: @vsn 8 | 9 | @moduledoc """ 10 | An Elixir Plug for requiring and extracting a given header. 11 | """ 12 | 13 | @doc """ 14 | Initialises the plug given a keyword list. 15 | """ 16 | def init(options), do: options 17 | 18 | @doc """ 19 | Extracts the required headers and assigns them to the connection struct. 20 | 21 | ## Arguments 22 | 23 | `conn` - the Plug.Conn connection struct 24 | `options` - a keyword list broken down into mandatory and optional options 25 | 26 | ### Mandatory options 27 | 28 | `:headers` - a keyword list of connection key and header key pairs. 29 | Each pair has the format `[: ]` where 30 | * the `` atom is the connection key to assign the value of 31 | the header. 32 | * the `` binary is the header key to be required and extracted. 33 | 34 | ### Optional options 35 | 36 | `:on_missing` - specifies how to handle a missing header. It can be one of 37 | the following: 38 | 39 | * a callback function with an arity of 2, specified as a tuple of 40 | `{module, function}`. The function will be called with the `conn` struct 41 | and a tuple consisting of a connection assignment key and header key pair. 42 | Notice that the callback may be invoked once per required header. 43 | * a keyword list with any or all of the following keys set. 44 | * `:status` - an `integer` or `atom` to specify the status code. If it's 45 | an atom, it'll be looked up using the `Plug.Status.code` function. 46 | Default is `:forbidden`. 47 | * `:message` - a `binary` sent as the response body. 48 | Default is an empty string. 49 | * `:as` - an `atom` describing the content type and encoding. Currently 50 | supported alternatives are `:text` for plain text and `:json` for JSON. 51 | Default is `:text`. 52 | 53 | If setting options instead of using a callback function, notice that the 54 | plug pipeline will _always_ be halted by a missing header, and the configured 55 | response will _only_ be sent _once_ 56 | """ 57 | def call(conn, options) do 58 | callback = on_missing(Keyword.fetch options, :on_missing) 59 | headers = Keyword.fetch! options, :headers 60 | extract_header_keys(conn, headers, callback) 61 | end 62 | 63 | defp on_missing({:ok, {module, function}}), do: use_callback(module, function) 64 | defp on_missing({:ok, config}) when config |> is_list, do: generate_callback(config) 65 | defp on_missing(_), do: generate_callback 66 | 67 | defp extract_header_keys(conn, [], _callback), do: conn 68 | defp extract_header_keys(conn, [header|remaining_headers], callback) do 69 | extract_header_key(conn, header, callback) 70 | |> extract_header_keys(remaining_headers, callback) 71 | end 72 | 73 | defp extract_header_key(conn, {connection_key, header_key}, callback) do 74 | case List.keyfind(conn.req_headers, header_key, 0) do 75 | {^header_key, value} -> assign_connection_key(conn, connection_key, value) 76 | _ -> callback.(conn, {connection_key, header_key}) 77 | end 78 | end 79 | 80 | defp assign_connection_key(conn, key, value) do 81 | conn |> assign(key, value) 82 | end 83 | 84 | defp use_callback(module, function) do 85 | fn(conn, missing_key_pair) -> 86 | apply module, function, [conn, missing_key_pair] 87 | end 88 | end 89 | 90 | defp generate_callback(config \\ []) do 91 | status = Keyword.get config, :status, Status.code(:forbidden) 92 | message = Keyword.get config, :message, "" 93 | format = Keyword.get config, :as, :text 94 | 95 | fn(conn, _) -> 96 | if conn.halted do 97 | conn 98 | else 99 | conn 100 | |> put_resp_content_type(content_type_for format) 101 | |> send_resp(status, format_message(message, format)) 102 | |> halt 103 | end 104 | end 105 | end 106 | 107 | defp content_type_for(:text), do: "text/plain" 108 | defp content_type_for(:json), do: "application/json" 109 | 110 | defp format_message(message, :text), do: message 111 | defp format_message(message, :json), do: Poison.encode! message 112 | end 113 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugRequireHeader.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :plug_require_header, 7 | version: "0.8.1", 8 | name: "PlugRequireHeader", 9 | source_url: "https://github.com/DevL/plug_require_header", 10 | elixir: "~> 1.0", 11 | deps: deps, 12 | description: description, 13 | package: package 14 | ] 15 | end 16 | 17 | defp description do 18 | """ 19 | An Elixir Plug for requiring and extracting a given header. 20 | """ 21 | end 22 | 23 | defp package do 24 | [ 25 | maintainers: ["Lennart Fridén", "Kim Persson"], 26 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 27 | licenses: ["MIT"], 28 | links: %{"GitHub" => "https://github.com/DevL/plug_require_header"} 29 | ] 30 | end 31 | 32 | def application do 33 | [applications: []] 34 | end 35 | 36 | defp deps do 37 | [ 38 | {:plug, "~> 1.1"}, 39 | {:poison, ">= 1.5.2"}, 40 | {:earmark, "~> 0.2", only: :dev}, 41 | {:ex_doc, "~> 0.11", only: :dev}, 42 | {:inch_ex, ">= 0.4.0", only: :docs} 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "0.2.1", "ba6d26ceb16106d069b289df66751734802777a3cbb6787026dd800ffeb850f3", [:mix], []}, 2 | "ex_doc": {:hex, :ex_doc, "0.12.0", "b774aabfede4af31c0301aece12371cbd25995a21bb3d71d66f5c2fe074c603f", [:mix], [{:earmark, "~> 0.2", [hex: :earmark, optional: false]}]}, 3 | "inch_ex": {:hex, :inch_ex, "0.5.3", "39f11e96181ab7edc9c508a836b33b5d9a8ec0859f56886852db3d5708889ae7", [:mix], [{:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]}, 4 | "plug": {:hex, :plug, "1.1.6", "8927e4028433fcb859e000b9389ee9c37c80eb28378eeeea31b0273350bf668b", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}]}, 5 | "poison": {:hex, :poison, "2.1.0", "f583218ced822675e484648fa26c933d621373f01c6c76bd00005d7bd4b82e27", [:mix], []}} 6 | -------------------------------------------------------------------------------- /test/plug_require_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugRequireHeaderTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | alias Plug.Conn.Status 5 | 6 | test "block request missing the required header" do 7 | connection = conn(:get, "/") 8 | response = TestApp.call(connection, []) 9 | 10 | assert response.status == Status.code(:forbidden) 11 | assert response.resp_body == "" 12 | assert content_type(response) == "text/plain; charset=utf-8" 13 | end 14 | 15 | test "block request with a header set, but without the required header" do 16 | connection = conn(:get, "/") |> put_req_header("x-wrong-header", "whatever") 17 | response = TestApp.call(connection, []) 18 | 19 | assert response.status == Status.code(:forbidden) 20 | assert response.resp_body == "" 21 | assert content_type(response) == "text/plain; charset=utf-8" 22 | end 23 | 24 | test "extract the required header and assign it to the connection" do 25 | connection = conn(:get, "/") |> put_req_header("x-api-key", "12345") 26 | response = TestApp.call(connection, []) 27 | 28 | assert response.status == Status.code(:ok) 29 | assert response.resp_body == "API key: 12345" 30 | end 31 | 32 | test "extract the required header even if multiple headers are set" do 33 | connection = conn(:get, "/") 34 | |> put_req_header("x-api-key", "12345") 35 | |> put_req_header("x-wrong-header", "whatever") 36 | response = TestApp.call(connection, []) 37 | 38 | assert response.status == Status.code(:ok) 39 | assert response.resp_body == "API key: 12345" 40 | end 41 | 42 | test "invoke a callback function if the required header is missing" do 43 | connection = conn(:get, "/") 44 | response = TestAppWithCallback.call(connection, []) 45 | 46 | assert response.status == Status.code(:precondition_failed) 47 | assert response.resp_body == "Missing header: x-api-key" 48 | end 49 | 50 | test "extract multiple required headers" do 51 | connection = conn(:get, "/") 52 | |> put_req_header("x-api-key", "12345") 53 | |> put_req_header("x-secret", "handshake") 54 | response = TestAppWithMultipleRequiredHeaders.call(connection, []) 55 | 56 | assert response.status == Status.code(:ok) 57 | assert response.resp_body == "API key: 12345 and the secret handshake" 58 | end 59 | 60 | test "block request missing one of several required headers" do 61 | connection = conn(:get, "/") 62 | |> put_req_header("x-api-key", "12345") 63 | response = TestAppWithMultipleRequiredHeaders.call(connection, []) 64 | 65 | assert response.status == Status.code(:forbidden) 66 | assert response.resp_body == "" 67 | assert content_type(response) == "text/plain; charset=utf-8" 68 | end 69 | 70 | test "block request missing multiple required headers" do 71 | connection = conn(:get, "/") 72 | response = TestAppWithMultipleRequiredHeaders.call(connection, []) 73 | 74 | assert response.status == Status.code(:forbidden) 75 | assert response.resp_body == "" 76 | end 77 | 78 | test "invoke a callback function if any of the required headers are missing" do 79 | connection = conn(:get, "/") 80 | |> put_req_header("x-api-key", "12345") 81 | response = TestAppWithCallbackAndMultipleRequiredHeaders.call(connection, []) 82 | 83 | assert response.status == Status.code(:ok) 84 | assert response.resp_body == "API key: 12345 and the secret is missing" 85 | end 86 | 87 | test "respond with configured text response on missing required headers" do 88 | connection = conn(:get, "/") 89 | response = TestAppRespondingWithText.call(connection, []) 90 | 91 | assert response.status == 418 92 | assert response.resp_body == "I'm a teapot!" 93 | assert content_type(response) == "text/plain; charset=utf-8" 94 | end 95 | 96 | test "respond with configured JSON response on missing required headers" do 97 | connection = conn(:get, "/") 98 | response = TestAppRespondingWithJSON.call(connection, []) 99 | 100 | assert response.status == 418 101 | assert response.resp_body == Poison.encode! %{error: "I'm a teapot!"} 102 | assert content_type(response) == "application/json; charset=utf-8" 103 | end 104 | 105 | defp content_type(response) do 106 | hd get_resp_header(response, "content-type") 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule AppMaker do 4 | defmacro __using__(options) do 5 | quote do 6 | use Plug.Router 7 | alias Plug.Conn.Status 8 | 9 | plug PlugRequireHeader, unquote(options) 10 | plug :match 11 | plug :dispatch 12 | end 13 | end 14 | end 15 | 16 | defmodule TestApp do 17 | use AppMaker, headers: [api_key: "x-api-key"] 18 | 19 | get "/" do 20 | send_resp(conn, Status.code(:ok), "API key: #{conn.assigns[:api_key]}") 21 | end 22 | end 23 | 24 | defmodule TestAppWithCallback do 25 | use AppMaker, headers: [api_key: "x-api-key"], on_missing: {__MODULE__, :callback} 26 | 27 | get "/" do 28 | send_resp(conn, Status.code(:ok), "#{conn.assigns[:api_key]}") 29 | end 30 | 31 | def callback(conn, {_, missing_header_key}) do 32 | conn 33 | |> send_resp(Status.code(:precondition_failed), "Missing header: #{missing_header_key}") 34 | |> halt 35 | end 36 | end 37 | 38 | defmodule TestAppWithMultipleRequiredHeaders do 39 | use AppMaker, headers: [api_key: "x-api-key", secret: "x-secret"] 40 | 41 | get "/" do 42 | send_resp(conn, Status.code(:ok), "API key: #{conn.assigns[:api_key]} and the secret #{conn.assigns[:secret]}") 43 | end 44 | end 45 | 46 | defmodule TestAppWithCallbackAndMultipleRequiredHeaders do 47 | use AppMaker, headers: [api_key: "x-api-key", secret: "x-secret"], on_missing: {__MODULE__, :callback} 48 | 49 | get "/" do 50 | send_resp(conn, Status.code(:ok), "API key: #{conn.assigns[:api_key]} and the secret #{conn.assigns[:secret]}") 51 | end 52 | 53 | def callback(conn, {connection_key, _}) do 54 | conn |> assign(connection_key, "is missing") 55 | end 56 | end 57 | 58 | defmodule TestAppRespondingWithJSON do 59 | use AppMaker, headers: [api_key: "x-api-key", secret: "x-secret"], on_missing: [status: 418, message: %{error: "I'm a teapot!"}, as: :json] 60 | 61 | get "/" do 62 | send_resp(conn, Status.code(:ok), "Never called") 63 | end 64 | end 65 | 66 | defmodule TestAppRespondingWithText do 67 | use AppMaker, headers: [api_key: "x-api-key", secret: "x-secret"], on_missing: [status: 418, message: "I'm a teapot!", as: :text] 68 | 69 | get "/" do 70 | send_resp(conn, Status.code(:ok), "Never called") 71 | end 72 | end 73 | --------------------------------------------------------------------------------