defmodule Bitbucket do @moduledoc """ Documentation for Bitbucket. """ use HTTPoison.Base alias Bitbucket.Client @user_agent [{"User-agent", "bitbucket-hex-package"}] @type response :: {:ok, Map.t(), HTTPoison.Response.t()} | {integer, any, HTTPoison.Response.t()} | pagination_response @type pagination_response :: {response, binary | nil, Client.auth()} @spec process_response_body(binary) :: term def process_response_body(""), do: nil def process_response_body(body) do case Poison.decode(body, deserialization_options()) do {:ok, result} -> result {:error, invalid, _} -> raise invalid {:error, invalid} -> raise invalid end end @spec process_response(HTTPoison.Response.t()) :: response def process_response(%HTTPoison.Response{status_code: status_code, body: body} = resp), do: {status_code, body, 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, `Bitbucket.Repositories.list_users("chrismccord")` and `Bitbucket.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 {:ok, encoded_body} = Poison.encode(body) raw_request(method, url, encoded_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 {:ok, encoded_body} = Poison.encode(body) request_with_pagination(method, url, auth, encoded_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 {:ok, encoded_body} = Poison.encode(body) resp = request!( method, url, encoded_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(), Client.auth()) :: pagination_response defp build_pagination_response(%HTTPoison.Response{:headers => headers} = resp, auth) do {process_response(resp), next_link(headers), auth} end 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/<(?.*)>;\s*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 is only one way to authenticate through Bitbucket API v2.0: * OAuth2 Token ## Examples iex> Bitbucket.authorization_header(%{access_token: "92873971893"}, []) [{"Authorization", "token 92873971893"}] ## More info https://developer.atlassian.com/bitbucket/api/2/reference/meta/authentication """ @spec authorization_header(Client.auth(), list) :: list def authorization_header(%{access_token: token}, headers) do headers ++ [{"Authorization", "Bearer #{token}"}] 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