Skip to content

Commit

Permalink
Merge pull request #3 from DevL/additional_options
Browse files Browse the repository at this point in the history
Enable extraction multiple headers and configure callback for missing headers.
  • Loading branch information
DevL committed Apr 4, 2015
2 parents 230aacb + 261dcaa commit 75b822d
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 46 deletions.
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ An Elixir Plug for requiring and extracting a given header.
Update your `mix.exs` file and run `mix deps.get`.
```elixir
defp deps do
[{:plug_require_header, "~> 0.2"}]
[{:plug_require_header, "~> 0.3"}]
end
```

Expand All @@ -21,25 +21,51 @@ defmodule MyPhoenixApp.MyController do
use MyPhoenixApp.Web, :controller
alias Plug.Conn.Status

plug PlugRequireHeader, api_key: "x-api-key"
plug PlugRequireHeader, headers: [api_key: "x-api-key"]
plug :action

def index(conn, _params) do
conn
|> put_status Status.code(:ok)
|> put_status(Status.code :ok)
|> text "The API key used is: #{conn.assigns[:api_key]}"
end
end
```
Notice how the first value required header `"x-api-key"` has been extracted and can be retrieved using `conn.assigns[:api_key]`. An alternative is to use `Plug.Conn.get_req_header/2` to get all the values associated with a given header.

By default, a missing header will return a status code of 403 (forbidden) and halt the plug pipeline, i.e. no subsequent plugs will be executed. The same is true if the required header is explicitly set to nil. This behaviour is to be configurable in a future version.
By default, a missing header will return a status code of 403 (forbidden) and halt the plug pipeline, i.e. no subsequent plugs will be executed. The same is true if the required header is explicitly set to `nil`. This behaviour however is configurable.
```elixir
defmodule MyPhoenixApp.MyOtherController do
use MyPhoenixApp.Web, :controller
alias Plug.Conn.Status

plug PlugRequireHeader, headers: [api_key: "x-api-key"], on_missing: {__MODULE__, :handle_missing_header}
plug :action

def index(conn, _params) do
conn
|> put_status(Status.code :ok)
|> text "The API key used is: #{conn.assigns[:api_key]}"
end

def handle_missing_header(conn, missing_header_key) do
conn
|> send_resp(Status.code(:bad_request), "Missing header: #{missing_header_key}")
|> halt
end
end
```
If the header is missing or set to `nil` the status code, a status code of 400 (bad request) will be returned before the plug pipeline is halted. Notice that the function specified as a callback needs to be a public function as it'll be invoked from another module.

Lastly, it's possible to extract multiple headers at the same time.

```elixir
plug PlugRequireHeader, headers: [api_key: "x-api-key", magic: "x-magic"]
```

## Planned features

* Require and extract multiple header keys and not just one.
* Make the action taken when a required header is missing configurable.
* Make the action taken when a required header is missing more configurable.
* given an atom -> look up the Plug.Conn.Status code.
* given an integer -> treat it as a status code.
* given a function -> invoke the function and pass it the `conn` struct and the missing header/connection key pair.
* configurable responses and content-types, e.g. JSON.
63 changes: 47 additions & 16 deletions lib/plug_require_header.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule PlugRequireHeader do
import Plug.Conn
alias Plug.Conn.Status

@vsn "0.2.1"
@vsn "0.3.0"
@doc false
def version, do: @vsn

Expand All @@ -11,37 +11,68 @@ defmodule PlugRequireHeader do
"""

@doc """
Initialises the plug given a keyword list of the following format.
Initialises the plug given a keyword list.
"""
def init(options), do: options

@doc """
Extracts the required headers and assigns them to the connection struct.
## Arguments
`conn` - the Plug.Conn connection struct
`options` - a keyword list broken down into mandatory and optional options
### Mandatory options
`:headers` - a keyword list of connection key and header key pairs.
Each pair has the format `[<connection_key>: <header_key>]` where
* the `<connection_key>` atom is the connection key to assign the value of
the header.
* the `<header_key>` binary is the header key to be required and extracted.
[<connection_key>: <header_key>]
### Optional options
* The `<connection_key>` atom is the connection key to assign the value of the header.
* The `<header_key>` binary is the header key to be required and extracted.
`:on_missing` - specifies how to handle a missing header. It can be one of
the following:
* a callback function with and arity of 2, specified as a tuple of
`{module, function}`. The function will be called with the `conn` struct
and the missing header key. Notice that the callback may be invoked once
per required header.
"""
def init(options) do
options |> List.first
def call(conn, options) do
callback = on_missing(Keyword.fetch options, :on_missing)
headers = Keyword.fetch! options, :headers
extract_header_keys(conn, headers, callback)
end

@doc """
Extracts the required headers and assign them to the connection struct.
"""
def call(conn, {connection_key, header_key}) do
extract_header_key(conn, connection_key, header_key)
defp on_missing({:ok, {module, function}}) do
fn (conn, missing_header_key) ->
apply module, function, [conn, missing_header_key]
end
end
defp on_missing(_), do: &halt_connection/2

defp extract_header_keys(conn, [], _callback), do: conn
defp extract_header_keys(conn, [header|remaining_headers], callback) do
extract_header_key(conn, header, callback)
|> extract_header_keys(remaining_headers, callback)
end

defp extract_header_key(conn, connection_key, header_key) do
defp extract_header_key(conn, {connection_key, header_key}, callback) do
case List.keyfind(conn.req_headers, header_key, 0) do
{^header_key, nil} -> halt_connection(conn)
{^header_key, nil} -> callback.(conn, header_key)
{^header_key, value} -> assign_connection_key(conn, connection_key, value)
_ -> halt_connection(conn)
_ -> callback.(conn, header_key)
end
end

defp assign_connection_key(conn, key, value) do
conn |> assign(key, value)
end

defp halt_connection(conn) do
defp halt_connection(conn, _) do
conn
|> send_resp(Status.code(:forbidden), "")
|> halt
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule PlugRequireHeader.Mixfile do
def project do
[
app: :plug_require_header,
version: "0.2.1",
version: "0.3.0",
name: "PlugRequireHeader",
source_url: "https://github.com/DevL/plug_require_header",
elixir: "~> 1.0",
Expand All @@ -22,7 +22,7 @@ defmodule PlugRequireHeader.Mixfile do

defp package do
[
contributors: ["Lennart Fridén"],
contributors: ["Lennart Fridén", "Kim Persson"],
files: ["lib", "mix.exs", "README*", "LICENSE*"],
licenses: ["MIT"],
links: %{"GitHub" => "https://github.com/DevL/plug_require_header"}
Expand Down
53 changes: 37 additions & 16 deletions test/plug_require_header_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,76 @@ defmodule PlugRequireHeaderTest do
use Plug.Test
alias Plug.Conn.Status

@options TestApp.init([])

test "block request missing the required header" do
connection = conn(:get, "/")
response = TestApp.call(connection, @options)
response = TestApp.call(connection, [])

assert response.status == Status.code(:forbidden)
assert response.resp_body == ""
end

test "block request with a header set, but without the required header" do
connection = conn(:get, "/") |> put_req_header("x-wrong-header", "whatever")
response = TestApp.call(connection, @options)
response = TestApp.call(connection, [])

assert response.status == Status.code(:forbidden)
assert response.resp_body == ""
end

test "block request with the required header set to nil" do
connection = conn(:get, "/") |> put_nil_header("x-api-key")
response = TestApp.call(connection, @options)
response = TestApp.call(connection, [])

assert response.status == Status.code(:forbidden)
assert response.resp_body == ""
end

test "extract the required header and assign it to the connection" do
api_key = "12345"

connection = conn(:get, "/") |> put_req_header("x-api-key", api_key)
response = TestApp.call(connection, @options)
connection = conn(:get, "/") |> put_req_header("x-api-key", "12345")
response = TestApp.call(connection, [])

assert response.status == Status.code(:ok)
assert response.resp_body == api_key
assert response.resp_body == "API key: 12345"
end

test "extract the required header even if multiple headers are set" do
api_key = "12345"

connection = conn(:get, "/")
|> put_req_header("x-api-key", api_key)
|> put_req_header("x-api-key", "12345")
|> put_req_header("x-wrong-header", "whatever")
response = TestApp.call(connection, @options)
response = TestApp.call(connection, [])

assert response.status == Status.code(:ok)
assert response.resp_body == "API key: 12345"
end

test "invoke a callback function if the required header is missing" do
connection = conn(:get, "/")
response = TestAppWithCallback.call(connection, [])

assert response.status == Status.code(:precondition_failed)
assert response.resp_body == "Missing header: x-api-key"
end

test "extract multiple required headers" do
connection = conn(:get, "/")
|> put_req_header("x-api-key", "12345")
|> put_req_header("x-secret", "handshake")
response = TestAppWithCallbackAndMultipleRequiredHeaders.call(connection, [])

assert response.status == Status.code(:ok)
assert response.resp_body == api_key
assert response.resp_body == "API key: 12345 and the secret handshake"
end

test "invoke a callback function if any of the required headers are missing" do
connection = conn(:get, "/")
|> put_req_header("x-api-key", "12345")
response = TestAppWithCallbackAndMultipleRequiredHeaders.call(connection, [])

assert response.status == Status.code(:bad_request)
assert response.resp_body == "Missing header: x-secret"
end

defp put_nil_header(%Plug.Conn{req_headers: headers} = conn, key) when is_binary(key) do
defp put_nil_header(%Plug.Conn{req_headers: headers} = conn, key) when is_binary(key) do
%{conn | req_headers: :lists.keystore(key, 1, headers, {key, nil})}
end
end
46 changes: 41 additions & 5 deletions test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -1,14 +1,50 @@
ExUnit.start()

defmodule AppMaker do
defmacro __using__(options) do
quote do
use Plug.Router
alias Plug.Conn.Status

plug PlugRequireHeader, unquote(options)
plug :match
plug :dispatch
end
end
end

defmodule TestApp do
use Plug.Router
alias Plug.Conn.Status
use AppMaker, headers: [api_key: "x-api-key"]

plug PlugRequireHeader, api_key: "x-api-key"
plug :match
plug :dispatch
get "/" do
send_resp(conn, Status.code(:ok), "API key: #{conn.assigns[:api_key]}")
end
end

defmodule TestAppWithCallback do
use AppMaker, headers: [api_key: "x-api-key"], on_missing: {__MODULE__, :callback}

get "/" do
send_resp(conn, Status.code(:ok), "#{conn.assigns[:api_key]}")
end

def callback(conn, missing_header_key) do
conn
|> send_resp(Status.code(:precondition_failed), "Missing header: #{missing_header_key}")
|> halt
end
end

defmodule TestAppWithCallbackAndMultipleRequiredHeaders do
use AppMaker, headers: [api_key: "x-api-key", secret: "x-secret"], on_missing: {__MODULE__, :callback}

get "/" do
send_resp(conn, Status.code(:ok), "API key: #{conn.assigns[:api_key]} and the secret #{conn.assigns[:secret]}")
end

def callback(conn, missing_header_key) do
conn
|> send_resp(Status.code(:bad_request), "Missing header: #{missing_header_key}")
|> halt
end
end

0 comments on commit 75b822d

Please sign in to comment.