Toolkit for building functional HTTP clients
An ideal client built with the library would expose a function per request kind that can be made to the target API.
Those functions would return data structures - operations - instead of actually executing requests.
The execution of a request would be done using HTTPAntidote.execute(operation, client)
or
HTTPAntidote.execute!(operation, client)
. The return value would be a properly decoded body - a map, struct, does not matter.
defmodule MyApp.Integrations do
def github_client() do
GithubApi.cached_client(fn opts ->
Map.put(opts, :api_key, System.get_env("GITHUB_API_KEY"))
end)
end
end
# somewhere else
import MyApp.Integrations
get_repo = GithubApi.Repo.get("elixir-lang", "elixir")
case HTTPAntidote.execute(get_repo, github_client()) do
{:ok, repo} ->
IO.inspect repo["name"]
{:error, error} ->
Logger.warn Exection.message(error)
end
All the configuration is provided through a HTTPAntidote.Client
struct. There are convenience functions for building it:
def from_env(app_name, key, override_fun) do
def from_env_cached(app_name, key, override_fun) do
They load the keyword using Application.get_env(app_name, key)
and call override_fun
on the result.
The final result is used to build the struct. The from_env_cashed
function will write the resulting client
into an ETS table and on subsequent uses will read from that table instead of resolving the configuration on each call.
A struct that holds all the data required to do a request
defstruct [
:method,
:path,
:params,
:body,
:adapter,
options: [],
headers: %{},
]
@type method :: :get | :post | :put | :patch | :delete | :option | :head
@type path :: String.t
@type params :: %{optional(String.t) => String.t}
@type headers :: %{optional(String.t) => [String.t]}
@type t(body) :: %__MODULE__{
method: method, path: path, params: params, body: body, headers: headers,
adapter: HTTPAntidote.Adapter.t, options: Keyword.t
}
A struct that holds the response from the server
defstruct [
:status,
:body,
headers: %{}
]
@type headers :: %{optional(String.t) => [String.t]}
@type t(body) :: %__MODULE__{
status: 100..999, body: body, headers: headers
}
This is a protocol that ties everything together.
defprotocol HTTPAntidote.Operation do
def to_request(operation, client)
def from_response(operation, reponse, client)
end
The author of an API client library will impelemnt the protocol at least once for an entity that represents the API request. This allows us to separate the construction of the request and acquiring various parameters from the actual execution. Additionally the impure part of actually executing the HTTP request is hidden from the developer of the API, it also means the impelemtation is independent of actual low-level HTTP library.
Responsible for actually executing the request. Behaviour with one callback
@callback execute(Request.t) :: {:ok, Response.t} | {:error, Exception.t}
We should support streaming responses. For this there is a second HTTPAntidote.Stream
protocol with one callback:
defprotocol HTTPAntidote.Stream do
def build(operation, client)
end
Which returns a stream. Internally it could use the requests as shown above to build the stream.
We should provide easy way to accomplish common tasks - urlencoding, JSON bodies and responses, multipart, etc. Maybe also client-side caching (?). It is not yet clear how that could work. One idea:
defimpl HTTPAntidote.Operation, for: Github.ReadOperation do
def to_request(%{endpoint: endpoint, params: params}, client) do
path = client.opts.base_url <> "/" <> endpoint
HTTPAntidote.Request.json_get(path, params, client)
|> HTTPAntidote.Request.put_header("x-api-key", client.opts.api_key)
end
def from_response(%{decoder: decoder}, response, _client) do
%{body: json} = HTTPAntidote.Response.json_decode(response)
decoder.(json)
end
end
defmodule Github.Repo do
alias Github.Operation
defstruct [:org, :name, ...]
def get(org, name) when is_bianry(org) and is_binary(repo) do
%Operation{endpoint: "/repo/#{org}/#{name}", params: %{}, decoder: &decode_repo}
end
defp decode_repo(json) do
%__MODULE__{org: json["org"], name: json["name"], ...}
end
end
The package would come with a mix httpantidote.gen.client
function that would seed an empty mix project
with a basic structure for an http client - the client building function, one struct for the operation
and a basic implementation.
- Middleware API - rate limitting
@michalmuskala When you say:
By "entity" do you mean some kind of struct that contains the client/context data needed for a certain kind of request to the API? Say we're making an OAuth API client of some kind, would you have some kind of a
%FetchToken{}
struct/entity containing the JWT, scopes, credentials, etc that implements theHTTPAntidote.Operation
protocol?