Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370defmodule GitHub.Client do @moduledoc """ Req-based HTTP client for the GitHub REST API with optional S3 ETag caching.
Resolves authentication (a token string, or a `GitHub.App` minting a JWT), performs conditional GETs against an S3 ETag cache when configured (304 responses are free against rate limits), and normalizes non-success responses into `GitHub.Error` structs — classifying rate-limit and server errors so callers can match on `reason`. """
require Logger
alias GitHub.App alias GitHub.Error
defmodule S3Config do @moduledoc false use TypedStruct
typedstruct enforce: true do field :bucket, String.t() field :access_key_id, String.t() field :secret_access_key, String.t() field :endpoint, String.t() field :region, String.t() field :plug, term(), enforce: false end end
@default_server "https://api.github.com" @http_code_success 200..206 @http_code_server_error 500..511
@type auth :: nil | String.t() | App.t() @type opt :: {:auth, auth()} | {:params, keyword() | nil} | {:body, map() | nil} | {:server, String.t()} | {:plug, term()} | {:s3_cache, S3Config.t()} @type result :: {:ok, term()} | {:error, Error.t()}
@client_opt_keys [:auth, :plug, :s3_cache, :server]
@doc """ Splits a resource function's options into client options, folding any remaining keys into `:params`. """ @spec to_request_opts(keyword()) :: [opt] def to_request_opts(opts) do {client, params} = Keyword.split(opts, @client_opt_keys) Keyword.put(client, :params, params) end
@doc "Issues a GET request. See `request/3`." @spec get(String.t(), [opt]) :: result() def get(url, opts \\ []), do: request(:get, url, opts)
@doc "Issues a POST request. `:body` is JSON-encoded. See `request/3`." @spec post(String.t(), [opt]) :: result() def post(url, opts \\ []), do: request(:post, url, opts)
@doc """ Issues a request and returns `{:ok, decoded_body}` or `{:error, %GitHub.Error{}}`. """ @spec request(atom(), String.t(), [opt]) :: result() def request(method, url, opts) do server = opts[:server] || @default_server s3_config = opts[:s3_cache] || s3_config() auth = opts[:auth] params = opts[:params]
headers = auth_headers(auth) ++ default_headers() full_url = Path.join(server, url)
{cached_entry, headers} = if s3_config && method == :get do maybe_add_etag(server, url, params, auth, s3_config, headers) else {nil, headers} end
req_opts = [ method: method, url: full_url, headers: headers, decode_body: false, redirect: true, retry: false ] |> maybe_add_body(opts[:body]) |> maybe_add_params(params) |> maybe_add_plug(opts[:plug])
case Req.request(Req.new(), req_opts) do {:ok, %Req.Response{} = response} -> process_response(method, url, server, params, auth, response, s3_config, cached_entry)
{:error, reason} -> {:error, Error.new( message: "Error during HTTP request", source: reason, step: {__MODULE__, :request} )} end end
@doc """ Returns the S3 cache config from application environment, or nil if unset. """ @spec s3_config() :: S3Config.t() | nil def s3_config do case Application.get_env(:tracker, :github_s3_cache) do nil -> nil opts -> struct!(S3Config, opts) end end
defp default_headers do [ {"accept", "application/vnd.github+json"}, {"user-agent", "Tracker"} ] end
defp auth_headers(nil), do: [] defp auth_headers(token) when is_binary(token), do: [{"authorization", "Bearer #{token}"}] defp auth_headers(%App{} = app), do: [{"authorization", "Bearer #{App.jwt(app)}"}]
defp maybe_add_body(opts, nil), do: opts defp maybe_add_body(opts, body), do: Keyword.put(opts, :json, body)
defp maybe_add_params(opts, nil), do: opts defp maybe_add_params(opts, []), do: opts defp maybe_add_params(opts, params), do: Keyword.put(opts, :params, params)
defp maybe_add_plug(opts, nil), do: opts defp maybe_add_plug(opts, plug), do: Keyword.put(opts, :plug, plug)
# Response handling
defp process_response( _method, _url, _server, _params, _auth, %Req.Response{status: 304}, _s3, entry ) when not is_nil(entry) do # 304 Not Modified — serve the cached body (free against rate limits). {:ok, decode_body(entry["response"])} end
defp process_response( method, url, server, params, auth, %Req.Response{} = response, s3_config, _entry ) do %Req.Response{status: status, body: body} = response
cond do status in @http_code_success -> if s3_config && method == :get do maybe_cache_response(server, url, params, auth, response, s3_config) end
{:ok, decode_body(body)}
true -> {:error, response_error(status, body, response)} end end
defp response_error(status, body, response) do decoded = safe_decode(body)
Error.new( code: status, reason: classify(status, decoded, response), message: error_message(status, decoded), source: body, step: {__MODULE__, :request} ) end
defp classify(status, _decoded, _response) when status in @http_code_server_error, do: :server_error
defp classify(status, decoded, response) do cond do rate_limited?(status, decoded, response) -> :rate_limited status == 404 -> :not_found true -> :error end end
defp rate_limited?(429, _decoded, _response), do: true
defp rate_limited?(403, decoded, response) do rate_limit_message?(decoded) or remaining_exhausted?(response) end
defp rate_limited?(_status, _decoded, _response), do: false
defp rate_limit_message?(%{"message" => "API rate limit exceeded" <> _}), do: true defp rate_limit_message?(%{"message" => "You have exceeded a secondary" <> _}), do: true defp rate_limit_message?(_), do: false
defp remaining_exhausted?(%Req.Response{} = response) do Req.Response.get_header(response, "x-ratelimit-remaining") == ["0"] end
defp error_message(_status, %{"message" => message}) when is_binary(message), do: message defp error_message(404, _decoded), do: "Not Found" defp error_message(status, _decoded), do: "GitHub API error (#{status})"
defp decode_body(""), do: nil defp decode_body(body) when is_binary(body), do: Jason.decode!(body)
defp safe_decode(body) when is_binary(body) do case Jason.decode(body) do {:ok, decoded} -> decoded _ -> nil end end
defp safe_decode(_), do: nil
# S3 ETag cache
defp maybe_add_etag(server, url, params, auth, s3_config, headers) do key = cache_key(server, url, params, auth)
case get_object(s3_config, key) do {:ok, body} -> case Jason.decode(body) do {:ok, %{"etag" => etag} = entry} -> {entry, [{"if-none-match", etag} | headers]}
_ -> {nil, headers} end
:miss -> {nil, headers} end end
defp maybe_cache_response(server, url, params, auth, %Req.Response{} = response, s3_config) do case Req.Response.get_header(response, "etag") do [etag | _] -> key = cache_key(server, url, params, auth)
entry = Jason.encode!(%{ etag: etag, response: response.body, headers: Map.new(response.headers) })
put_object(s3_config, key, entry)
[] -> :ok end end
@doc """ Builds the S3 cache key for a request.
Format: `github_cache/{host}{path}/{query_hash}_{auth_hash}` (the query hash is omitted when there are no params). """ @spec cache_key(String.t(), String.t(), keyword() | nil, auth()) :: String.t() def cache_key(server, url, params, auth) do host = URI.parse(server).host
query_hash = if params && params != [] do params |> Enum.sort_by(fn {k, _} -> to_string(k) end) |> URI.encode_query() |> short_hash() end
auth_hash = case auth do token when is_binary(token) -> short_hash(token) _ -> "anon" end
suffix = if query_hash, do: "#{query_hash}_#{auth_hash}", else: auth_hash
"github_cache/#{host}#{url}/#{suffix}" end
defp short_hash(value) do :crypto.hash(:sha256, value) |> Base.encode16(case: :lower) |> binary_part(0, 12) end
# S3 I/O
defp s3_req(config) do req = Req.new()
if config.plug do Req.merge(req, plug: config.plug) else ReqS3.attach(req, aws_sigv4: [ access_key_id: config.access_key_id, secret_access_key: config.secret_access_key, region: config.region ], aws_endpoint_url_s3: config.endpoint ) end end
defp s3_url(config, key) do if config.plug do "/#{config.bucket}/#{key}" else "s3://#{config.bucket}/#{key}" end end
defp get_object(config, key) do case Req.get(s3_req(config), url: s3_url(config, key), decode_body: false, retry: false) do {:ok, %Req.Response{status: 200, body: body}} -> {:ok, body} _ -> :miss end end
defp put_object(config, key, body) do case Req.put(s3_req(config), url: s3_url(config, key), body: body) do {:ok, %Req.Response{status: status}} when status in 200..299 -> :ok
{:ok, %Req.Response{status: status, body: resp_body}} -> Logger.error( msg: "failed to write GitHub S3 cache", key: key, status: status, body: inspect(resp_body) )
:error
{:error, reason} -> Logger.error(msg: "failed to write GitHub S3 cache", key: key, reason: inspect(reason)) :error end endend