Skip to content

Instantly share code, notes, and snippets.

@michalmuskala
Last active April 16, 2019 00:07
Show Gist options
  • Save michalmuskala/5cee518b918aa5a441e757efca965d22 to your computer and use it in GitHub Desktop.
Save michalmuskala/5cee518b918aa5a441e757efca965d22 to your computer and use it in GitHub Desktop.

HTTPAntidote

Toolkit for building functional HTTP clients

Usage

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.

Example

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

Concepts

Client

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.

Request

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
  }

Response

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
  }

Operation

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.

Adapter

Responsible for actually executing the request. Behaviour with one callback

@callback execute(Request.t) :: {:ok, Response.t} | {:error, Exception.t}

Streaming

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.

Utils

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

Genrators

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.

Questions

  • Middleware API - rate limitting
@ryanwinchester
Copy link

ryanwinchester commented Jun 20, 2018

I've been working (slowly) for a while now on a fork of HTTPoison that has request and response structs similar to what you've got here, actually. It's mostly an experiment, but it works.

You've got some interesting ideas here, though, and you've got me thinking...

@zblanco
Copy link

zblanco commented Feb 21, 2019

@michalmuskala When you say:

"... an API client library will implement the protocol at least once for an entity that represents the API request..."

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 the HTTPAntidote.Operation protocol?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment