defmodule Tentacat do
use HTTPoison.Base
alias Tentacat.Client
alias Jason
@user_agent [{"User-agent", "tentacat"}]
@type response ::
{:ok, term, HTTPoison.Response.t()}
| {integer, any, HTTPoison.Response.t()}
| pagination_response
@type pagination_response :: {response, binary | nil, Client.auth()}
defimpl Jason.Encoder, for: Tuple do
def encode(tuple, opts) when is_tuple(tuple) do
[tuple]
|> Enum.into(%{})
|> Jason.Encode.map(opts)
end
end
@spec process_response_body(binary) :: term
def process_response_body(""), do: nil
def process_response_body(body), do: Jason.decode!(body, deserialization_options())
@spec process_response(HTTPoison.Response.t() | {integer, any, HTTPoison.Response.t()}) ::
response
def process_response(%HTTPoison.Response{status_code: status_code, body: body} = resp),
do: {status_code, body, resp}
def process_response({_status_code, _, %HTTPoison.Response{} = resp}),
do: process_response(resp)
@spec delete(binary, Client.t(), any) :: response
def delete(path, client, body \\ "") do
_request(:delete, url(client, path), client.auth, body)
end
@spec post(binary, Client.t(), any) :: response
def post(path, client, body \\ "") do
_request(:post, url(client, path), client.auth, body)
end
@spec patch(binary, Client.t(), any) :: response
def patch(path, client, body \\ "") do
_request(:patch, url(client, path), client.auth, body)
end
@spec put(binary, Client.t(), any) :: response
def put(path, client, body \\ "") do
_request(:put, url(client, path), client.auth, body)
end
@doc """
Underlying utility retrieval function. The options passed affect both the
return value and, ultimately, the number of requests made to GitHub.
## Options
* `:pagination` - Can be `:none`, `:manual`, `:stream`, or `:auto`. Defaults to :auto.
- `:none` will only return the first page. You won't have access to the
headers to manually paginate.
- `:auto` will block until all the pages have been retrieved and
concatenated together. Most of the time, this is what you want. For
example, `Tentacat.Repositories.list_users("chrismccord")` and
`Tentacat.Repositories.list_users("octocat")` have the same interface
though one call will page many times and the other not at all.
- `:stream` will return a `Stream`, prepopulated with the first page.
- `:manual` will return a 3 element tuple of `{page_body,
url_for_next_page, auth_credentials}`, which will allow you to control
the paging yourself.
"""
@spec get(binary, Client.t()) :: response
@spec get(binary, Client.t(), keyword) :: response
@spec get(binary, Client.t(), keyword, keyword) ::
response | Enumerable.t() | pagination_response
def get(path, client, params \\ [], options \\ []) do
url =
client
|> url(path)
|> add_params_to_url(params)
case pagination(options) do
nil -> request_stream(:get, url, client.auth)
:none -> request_stream(:get, url, client.auth, "", :one_page)
:auto -> request_stream(:get, url, client.auth)
:stream -> request_stream(:get, url, client.auth, "", :stream)
:manual -> request_with_pagination(:get, url, client.auth)
end
end
@spec _request(atom, binary, Client.auth(), any) :: response
def _request(method, url, auth, body \\ "") do
json_request(method, url, body, authorization_header(auth, @user_agent))
end
@spec json_request(atom, binary, any, keyword, keyword) :: response
def json_request(method, url, body \\ "", headers \\ [], options \\ []) do
raw_request(method, url, Jason.encode!(body), headers, options)
end
defp extra_options do
Application.get_env(:tentacat, :request_options, [])
end
defp extra_headers do
Application.get_env(:tentacat, :extra_headers, [])
end
defp deserialization_options do
Application.get_env(:tentacat, :deserialization_options, labels: :binary)
end
@spec pagination(keyword) :: atom | nil
defp pagination(options) do
Keyword.get(options, :pagination, Application.get_env(:tentacat, :pagination, nil))
end
def raw_request(method, url, body \\ "", headers \\ [], options \\ []) do
method
|> request!(url, body, extra_headers() ++ headers, extra_options() ++ options)
|> process_response
end
@spec request_stream(atom, binary, Client.auth(), any, :one_page | nil | :stream) ::
Enumerable.t() | response
def request_stream(method, url, auth, body \\ "", override \\ nil) do
request_with_pagination(method, url, auth, Jason.encode!(body))
|> stream_if_needed(override)
end
@spec stream_if_needed(pagination_response, :one_page | nil) :: response
@spec stream_if_needed({response, binary | nil, Client.auth()}, :stream) :: Enumerable.t()
defp stream_if_needed({response, _, _}, :one_page), do: response
defp stream_if_needed({response, nil, _}, _), do: response
defp stream_if_needed(initial_results = {response, _, _}, nil) do
{elem(response, 0),
Enum.to_list(Stream.resource(fn -> initial_results end, &process_stream/1, fn _ -> nil end)),
elem(response, 2)}
end
defp stream_if_needed(initial_results, :stream) do
Stream.resource(fn -> initial_results end, &process_stream/1, fn _ -> nil end)
end
defp process_stream({[], nil, _}), do: {:halt, nil}
defp process_stream({[], next, auth}) do
request_with_pagination(:get, next, auth, "")
|> process_stream
end
defp process_stream({{_, items, _}, next, auth}) when is_list(items) do
{items, {[], next, auth}}
end
defp process_stream({item, next, auth}) do
{[item], {[], next, auth}}
end
@spec request_with_pagination(atom, binary, Client.auth(), any) :: pagination_response
def request_with_pagination(method, url, auth, body \\ "") do
resp =
request!(
method,
url,
Jason.encode!(body),
authorization_header(auth, extra_headers() ++ @user_agent),
extra_options()
)
case process_response(resp) do
{status, _, _} when status in [301, 302, 307] ->
request_with_pagination(method, location_header(resp), auth)
_ ->
build_pagination_response(resp, auth)
end
end
@spec build_pagination_response(
HTTPoison.Response.t() | {integer, any, HTTPoison.Response.t()},
Client.auth()
) :: pagination_response
defp build_pagination_response(%HTTPoison.Response{:headers => headers} = resp, auth) do
{process_response(resp), next_link(headers), auth}
end
defp build_pagination_response({_, _, %HTTPoison.Response{} = resp}, auth) do
build_pagination_response(resp, auth)
end
defp location_header({_, _, resp}),
do: location_header(resp)
defp location_header(resp) do
[{"Location", url}] = Enum.filter(resp.headers, &match?({"Location", _}, &1))
url
end
@spec next_link(list) :: binary | nil
defp next_link(headers) do
for {"Link", link_header} <- headers,
links <- String.split(link_header, ",") do
Regex.named_captures(~r/<(?<link>.*)>;\s*rel=\"(?<rel>.*)\"/, links)
|> case do
%{"link" => link, "rel" => "next"} -> link
_ -> nil
end
end
|> Enum.filter(&(not is_nil(&1)))
|> List.first()
end
@spec url(client :: Client.t(), path :: binary) :: binary
defp url(_client = %Client{endpoint: endpoint}, path) do
endpoint <> path
end
@doc """
Take an existing URI and add addition params, appending and replacing as necessary.
## Examples
iex> add_params_to_url("http://example.com/wat", [])
"http://example.com/wat"
iex> add_params_to_url("http://example.com/wat", [q: 1])
"http://example.com/wat?q=1"
iex> add_params_to_url("http://example.com/wat", [q: 1, t: 2])
"http://example.com/wat?q=1&t=2"
iex> add_params_to_url("http://example.com/wat", %{q: 1, t: 2})
"http://example.com/wat?q=1&t=2"
iex> add_params_to_url("http://example.com/wat?q=1&t=2", [])
"http://example.com/wat?q=1&t=2"
iex> add_params_to_url("http://example.com/wat?q=1", [t: 2])
"http://example.com/wat?q=1&t=2"
iex> add_params_to_url("http://example.com/wat?q=1", [q: 3, t: 2])
"http://example.com/wat?q=3&t=2"
iex> add_params_to_url("http://example.com/wat?q=1&s=4", [q: 3, t: 2])
"http://example.com/wat?q=3&s=4&t=2"
iex> add_params_to_url("http://example.com/wat?q=1&s=4", %{q: 3, t: 2})
"http://example.com/wat?q=3&s=4&t=2"
"""
@spec add_params_to_url(binary, list) :: binary
def add_params_to_url(url, params) do
url
|> URI.parse()
|> merge_uri_params(params)
|> String.Chars.to_string()
end
@spec merge_uri_params(URI.t(), list) :: URI.t()
defp merge_uri_params(uri, []), do: uri
defp merge_uri_params(%URI{query: nil} = uri, params) when is_list(params) or is_map(params) do
uri
|> Map.put(:query, URI.encode_query(params))
end
defp merge_uri_params(%URI{} = uri, params) when is_list(params) or is_map(params) do
uri
|> Map.update!(:query, fn q ->
q
|> URI.decode_query()
|> Map.merge(param_list_to_map_with_string_keys(params))
|> URI.encode_query()
end)
end
@spec param_list_to_map_with_string_keys(list) :: map
defp param_list_to_map_with_string_keys(list) when is_list(list) or is_map(list) do
for {key, value} <- list, into: Map.new() do
{"#{key}", value}
end
end
@doc """
There are two ways to authenticate through GitHub API v3:
* Basic authentication
* OAuth2 Token
* JWT
This function accepts both.
## Examples
iex> Tentacat.authorization_header(%{user: "user", password: "password"}, [])
[{"Authorization", "Basic dXNlcjpwYXNzd29yZA=="}]
iex> Tentacat.authorization_header(%{access_token: "92873971893"}, [])
[{"Authorization", "token 92873971893"}]
iex> Tentacat.authorization_header(%{jwt: "92873971893"}, [])
[{"Authorization", "Bearer 92873971893"}]
More info at: http://developer.github.com/v3/#authentication
"""
@spec authorization_header(Client.auth(), list) :: list
def authorization_header(%{user: user, password: password}, headers) do
userpass = "#{user}:#{password}"
headers ++ [{"Authorization", "Basic #{:base64.encode(userpass)}"}]
end
def authorization_header(%{access_token: token}, headers) do
headers ++ [{"Authorization", "token #{token}"}]
end
def authorization_header(%{jwt: jwt}, headers) do
headers ++ [{"Authorization", "Bearer #{jwt}"}]
end
def authorization_header(_, headers), do: headers
@doc """
Same as `authorization_header/2` but defaults initial headers to include `@user_agent`.
"""
def authorization_header(options), do: authorization_header(options, @user_agent)
end