Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use a pluggable authorizer to calculated the Authorization header field value. #65

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,43 @@ iex> Stream.map(collection, fn follow_results ->
["http://example.com/beginning", "http://example.com/middle", "http://example.com/end"]
```

### Authorization

The `Authorization` header field is automatically populated using the "authorizer" specified on the client. Currently, the only implemented authorizer is `ExHal.SimpleAuthorizer`. This authorizer returns the specified `Authorization` header field value for any resource with a specified URL prefix.

```elixir
iex> token = fetch_api_token()
iex> authorizer = ExHal.SimpleAuthorizer.new("http://example.com/", "Bearer #{token}")
iex> client = ExHal.client()
...> |> ExHal.Client.set_authorizer(authorizer)
iex> ExHal.get("http://example.com/entrypoint") # request will included `Authorization: Bearer ...` request header
%ExHal.Document{...}
```

You can implement your own authorizer if you want. Simply implement `ExHal.Authorizer` protocol.

```elixir
iex> defmodule MyAuth do
...> defstruct([:p])
...> def connect() do
...> pid = start_token_management_process()
...> %MyAuth{p: pid}
...> end
...> end
iex> defimpl ExHal.Authorizer, for: MyAuth do
...> def authorization(authorizer, url) do
...> t = GenServer.call(authorizer.p, :get_token)
...> {:ok, "Bearer #{t}"}
...> end
...> end
...>
iex> authorizer = MyAuth.connect
iex> client = ExHal.client()
...> |> ExHal.Client.set_authorizer(authorizer)
iex> ExHal.get("http://example.com/entrypoint") # request will included `Authorization: Bearer ...` request header with a token fetched from the token management process.
%ExHal.Document{...}
```

### Serialization

Collections and Document can render themselves to a json-like
Expand Down
11 changes: 4 additions & 7 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ use Mix.Config
# format: "$date $time [$level] $metadata$message\n",
# metadata: [:user_id]

# It is also possible to import configuration files, relative to this
# directory. For example, you can emulate configuration per environment
# by uncommenting the line below and defining dev.exs, test.exs and such.
# Configuration from the imported file will override the ones defined
# here (which is why it is important to import them last).
#
# import_config "#{Mix.env}.exs"
if :test == Mix.env do
config :exhal, :client, ExHal.ClientMock
config :exhal, :http_client, ExHal.HttpClientMock
end
3 changes: 2 additions & 1 deletion lib/exhal/assertions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ defmodule ExHal.Assertions do

# internal functions

@spec p_assert_property(String.t | Document.t, String.t, (any() -> boolean()), String.t) :: any()
@spec p_assert_property(String.t() | Document.t(), String.t(), (any() -> boolean()), String.t()) ::
any()
def p_assert_property(doc, prop_name, check_fn, check_desc) when is_binary(doc) do
p_assert_property(Document.parse!(doc), prop_name, check_fn, check_desc)
end
Expand Down
35 changes: 35 additions & 0 deletions lib/exhal/authorizer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defprotocol ExHal.Authorizer do
@typedoc """
The value of the `Authorization` header field.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this has been generalized, and the doc should be updated

"""
@type credentials :: String.t()

@typedoc """
A URL.
"""
@type url :: String.t()

@typedoc """
An object that implements the ExHal.Authorizer protocol.
"""
@type authorizer :: any()

@typedoc """
Name of a HTTP header field.
"""
@type header_field_name :: String.t

@doc """

Called before each request to calculate any header fields needed to
authorize the request. A common return would be

%{"Authorization" => "Bearer <sometoken>"}

If the URL is unrecognized or no header fields are appropriate or
needed this function should return and empty map.

"""
@spec authorization(authorizer, url()) :: %{optional(header_field_name()) => String.t()}
Copy link
Contributor

@adherr adherr Oct 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused how a Map key can be optional. Also, should the map value type be credentials?

def authorization(authorizer, url)
end
111 changes: 76 additions & 35 deletions lib/exhal/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,57 +5,91 @@ defmodule ExHal.Client do
## Examples

iex> ExHal.Client.new()
%ExHal.Client{}
...> |> ExHal.Client.get("http://haltalk.herokuapp.com/")
%ExHal.Document{...}

iex> ExHal.Client.new()
...> |> ExHal.Client.post("http://haltalk.herokuapp.com/signup", ~s(
...> { "username": "fred",
...> "password": "pwnme",
...> "real_name": "Fred Wilson" }
...> ))
%ExHal.Document{...}

iex> authorizer = ExHal.SimpleAuthorizer.new("http://haltalk.herokuapp.com",
...> "Bearer my-token")
iex> ExHal.Client.new()
...> |> ExHal.Client.add_headers("Prefer": "minimal")
...> |> ExHal.Client.set_authorizer(authorizer)
%ExHal.Client{...}
"""

require Logger
alias ExHal.{Document, NonHalResponse, ResponseHeader}
alias ExHal.{Document, NonHalResponse, ResponseHeader, Authorizer, NullAuthorizer}

@logger Application.get_env(:exhal, :logger, Logger)
@http_client Application.get_env(:exhal, :http_client, HTTPoison)
pezra marked this conversation as resolved.
Show resolved Hide resolved

@typedoc """
Represents a client configuration/connection. Create with `new` function.
"""
@opaque t :: %__MODULE__{}
defstruct headers: [], opts: [follow_redirect: true]
defstruct authorizer: NullAuthorizer.new(),
headers: %{},
opts: [follow_redirect: true]

@typedoc """
The return value of any function that makes an HTTP request.
"""
@type http_response ::
{:ok, Document.t() | NonHalResponse.t(), ResponseHeader.t()}
| {:error, Document.t() | NonHalResponse.t(), ResponseHeader.t() }
| {:error, Error.t()}
{:ok, Document.t() | NonHalResponse.t(), ResponseHeader.t()}
| {:error, Document.t() | NonHalResponse.t(), ResponseHeader.t()}
| {:error, Error.t()}

@doc """
Returns a new client.
"""
@spec new(Keyword.t(), Keyword.t()) :: __MODULE__.t()
def new(headers, follow_redirect: follow) do
%__MODULE__{headers: headers, opts: [follow_redirect: follow]}
end
@spec new() :: t
def new(), do: %__MODULE__{}

@spec new(Keyword.t()) :: t
def new(headers: headers),
do: %__MODULE__{headers: normalize_headers(headers), opts: [follow_redirect: true]}

def new(follow_redirect: follow), do: %__MODULE__{opts: [follow_redirect: follow]}

def new(headers: headers, follow_redirect: follow),
do: %__MODULE__{headers: normalize_headers(headers), opts: [follow_redirect: follow]}

@spec new(Keyword.t()) :: __MODULE__.t()
def new(headers) do
new(headers, follow_redirect: true)
# deprecated call patterns
def new(headers) when is_list(headers) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do these need the @deprecated annotation?

%__MODULE__{headers: normalize_headers(headers), opts: [follow_redirect: true]}
end

@spec new() :: __MODULE__.t()
def new() do
new([], follow_redirect: true)
@spec new(Keyword.t(), Keyword.t()) :: t
def new(headers, follow_redirect: follow) do
new(headers: headers, follow_redirect: follow)
end

@doc """
Returns client that will include the specified headers in any request
made with it.
"""
@spec add_headers(__MODULE__.t(), Keyword.t()) :: __MODULE__.t()
@spec add_headers(t, Keyword.t()) :: t
def add_headers(client, headers) do
updated_headers = merge_headers(client.headers, headers)
updated_headers = merge_headers(client.headers, normalize_headers(headers))

%__MODULE__{client | headers: updated_headers}
end

@doc """
Returns a client that will authorize requests using the specified authorizer.
"""
@spec set_authorizer(t, Authorizer.t()) :: t
def set_authorizer(client, new_authorizer) do
%__MODULE__{client | authorizer: new_authorizer}
end

defmacrop log_req(method, url, do: block) do
quote do
{time, result} = :timer.tc(fn -> unquote(block) end)
Expand All @@ -64,60 +98,63 @@ defmodule ExHal.Client do
end
end

@callback get(__MODULE__.t, String.t, Keyword.t) :: http_response()
@callback get(__MODULE__.t(), String.t(), Keyword.t()) :: http_response()
def get(client, url, opts \\ []) do
{headers, poison_opts} = figure_headers_and_opt(opts, client)
{headers, poison_opts} = figure_headers_and_opt(opts, client, url)

log_req("GET", url) do
HTTPoison.get(url, headers, poison_opts)
@http_client.get(url, headers, poison_opts)
|> extract_return(client)
end
end

@callback post(__MODULE__.t, String.t, <<>>, Keyword.t) :: http_response()
@callback post(__MODULE__.t(), String.t(), <<>>, Keyword.t()) :: http_response()
def post(client, url, body, opts \\ []) do
{headers, poison_opts} = figure_headers_and_opt(opts, client)
{headers, poison_opts} = figure_headers_and_opt(opts, client, url)

log_req("POST", url) do
HTTPoison.post(url, body, headers, poison_opts)
@http_client.post(url, body, headers, poison_opts)
|> extract_return(client)
end
end

@callback put(__MODULE__.t, String.t, <<>>, Keyword.t) :: http_response()
@callback put(__MODULE__.t(), String.t(), <<>>, Keyword.t()) :: http_response()
def put(client, url, body, opts \\ []) do
{headers, poison_opts} = figure_headers_and_opt(opts, client)
{headers, poison_opts} = figure_headers_and_opt(opts, client, url)

log_req("PUT", url) do
HTTPoison.put(url, body, headers, poison_opts)
@http_client.put(url, body, headers, poison_opts)
|> extract_return(client)
end
end

@callback patch(__MODULE__.t, String.t, <<>>, Keyword.t) :: http_response()
@callback patch(__MODULE__.t(), String.t(), <<>>, Keyword.t()) :: http_response()
def patch(client, url, body, opts \\ []) do
{headers, poison_opts} = figure_headers_and_opt(opts, client)
{headers, poison_opts} = figure_headers_and_opt(opts, client, url)

log_req("PATCH", url) do
HTTPoison.patch(url, body, headers, poison_opts)
@http_client.patch(url, body, headers, poison_opts)
|> extract_return(client)
end
end

# Private functions

defp figure_headers_and_opt(opts, client) do
{local_headers, local_opts} = Keyword.pop(Keyword.new(opts), :headers, [])
defp figure_headers_and_opt(opts, client, url) do
{local_headers, local_opts} = Keyword.pop(Keyword.new(opts), :headers, %{})

headers =
client.headers
|> merge_headers(normalize_headers(local_headers))
|> merge_headers(Authorizer.authorization(client.authorizer, url))

headers = merge_headers(client.headers, local_headers)
poison_opts = merge_poison_opts(client.opts, local_opts)

{headers, poison_opts}
end

defp merge_headers(old_headers, new_headers) do
old_headers
|> Keyword.merge(new_headers, fn _k, v1, v2 -> List.wrap(v1) ++ List.wrap(v2) end)
Map.merge(old_headers, new_headers, fn _k, v1, v2 -> List.wrap(v1) ++ List.wrap(v2) end)
end

@default_poison_opts [follow_redirect: true]
Expand Down Expand Up @@ -150,4 +187,8 @@ defmodule ExHal.Client do
{:error, _} -> NonHalResponse.from_httpoison_response(resp)
end
end

defp normalize_headers(headers) do
Enum.into(headers, %{}, fn {k, v} -> {to_string(k), v} end)
end
end
17 changes: 9 additions & 8 deletions lib/exhal/document.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ defmodule ExHal.Document do
}
end

def from_parsed_hal(client = %ExHal.Client{}, parsed_hal), do: from_parsed_hal(parsed_hal, client)
def from_parsed_hal(client = %ExHal.Client{}, parsed_hal),
do: from_parsed_hal(parsed_hal, client)

@doc """
Returns true iff the document contains at least one link with the specified rel.
Expand Down Expand Up @@ -223,29 +224,29 @@ defmodule ExHal.Document do
namespaces = NsReg.from_parsed_json(parsed_json)
embedded_links = embedded_links_in(client, parsed_json)

links = simple_links_in(parsed_json)
|> augment_simple_links_with_embedded_reprs(embedded_links)
|> backfill_missing_links(embedded_links)
|> expand_curies(namespaces)
links =
simple_links_in(parsed_json)
|> augment_simple_links_with_embedded_reprs(embedded_links)
|> backfill_missing_links(embedded_links)
|> expand_curies(namespaces)

Enum.group_by(links, fn a_link -> a_link.rel end)
end

defp augment_simple_links_with_embedded_reprs(links, embedded_links) do
links
|> Enum.map(fn link ->
case Enum.find(embedded_links, &(Link.equal?(&1, link))) do
case Enum.find(embedded_links, &Link.equal?(&1, link)) do
nil -> link
embedded -> %{link | target: embedded.target}
end
end)
end


defp backfill_missing_links(links, embedded_links) do
embedded_links
|> Enum.reduce(links, fn embedded, links ->
case Enum.any?(links, &(Link.equal?(embedded, &1))) do
case Enum.any?(links, &Link.equal?(embedded, &1)) do
false -> [embedded | links]
_ -> links
end
Expand Down
13 changes: 5 additions & 8 deletions lib/exhal/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,10 @@ defmodule ExHal.Form do
"""
@spec submit(__MODULE__.t(), Client.t()) :: Client.http_response()
def submit(form, client) do
apply(client_module(),
apply(
client_module(),
form.method,
[client,
form.target,
encode(form),
[headers: ["Content-Type": form.content_type]]
]
[client, form.target, encode(form), [headers: ["Content-Type": form.content_type]]]
)
end

Expand Down Expand Up @@ -124,8 +121,8 @@ defmodule ExHal.Form do

defp extract_method(a_map) do
Map.get_lazy(a_map, "method", fn -> raise ArgumentError, "form method missing" end)
|> String.downcase
|> String.to_atom
|> String.downcase()
|> String.to_atom()
end

defp extract_content_type(a_map) do
Expand Down
14 changes: 14 additions & 0 deletions lib/exhal/http_client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule ExHal.HttpClient do
@callback get(String.t(), HTTPoison.Base.headers(), Keyword.t()) ::
{:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()}
| {:error, HTTPoison.Error.t()}
@callback post(String.t(), any, HTTPoison.Base.headers(), Keyword.t()) ::
{:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()}
| {:error, HTTPoison.Error.t()}
@callback put(String.t(), any, HTTPoison.Base.headers(), Keyword.t()) ::
{:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()}
| {:error, HTTPoison.Error.t()}
@callback patch(String.t(), any, HTTPoison.Base.headers(), Keyword.t()) ::
{:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()}
| {:error, HTTPoison.Error.t()}
end
Loading