Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tesla Adapter #57

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
language: elixir
elixir:
- 1.2.3
- 1.3.4
otp_release:
- 18.2.1
env:
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ Using the configuration you're allowed to set headers that will be sent on every

### HTTP Client Adapter

Currently the only adapter available is [HTTPoisonAdapter](https://github.com/inaka/Dayron/blob/master/lib/dayron/adapters/httpoison_adapter.ex), which uses [HTTPoison](https://github.com/edgurgel/httpoison) and [hackney](https://github.com/benoitc/hackney) to manage HTTP requests.
Currently the adapters available are:
- [HTTPoisonAdapter](https://github.com/inaka/Dayron/blob/master/lib/dayron/adapters/httpoison_adapter.ex), which uses [HTTPoison](https://github.com/edgurgel/httpoison) and [hackney](https://github.com/benoitc/hackney) to manage HTTP requests
- [TeslaAdapter](https://github.com/inaka/Dayron/blob/master/lib/dayron/adapters/tesla_adapter.ex), which uses [Tesla](https://github.com/teamon/tesla) and [hackney](https://github.com/benoitc/hackney) to manage HTTP requests

**NOTE:** While the HTTPoison adapter accepts the `:stream_to` argument and passes it on to HTTPoison, streaming isn't very well supported yet as it's not handled by the adapter in a generic way. The Tesla adapter currently ignores the option. See [discussion in issue #54](https://github.com/inaka/Dayron/issues/54#issuecomment-253715077).

You can also create your own adapter implementing the [Dyron.Adapter](https://github.com/inaka/Dayron/blob/master/lib/dayron/adapter.ex) behavior, and changing the configuration to something like:

Expand Down
137 changes: 137 additions & 0 deletions lib/dayron/adapters/tesla_adapter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
defmodule Dayron.TeslaAdapter do
@moduledoc """
Makes http requests using Tesla library.
Use this adapter to make http requests to an external Rest API.

## Example config
config :my_app, MyApp.Repo,
adapter: Dayron.TeslaAdapter,
url: "https://api.example.com"

## TODO

- Handle streaming
"""
@behaviour Dayron.Adapter

defmodule Client do
@moduledoc """
A Tesla Client implementation, sending json requests, parsing
json responses to Maps or a List of Maps. Maps keys are also converted to
atoms by default.
"""
use Tesla

plug Tesla.Middleware.EncodeJson, engine: Poison
plug Dayron.TeslaAdapter.Translator

adapter Tesla.Adapter.Hackney
end

defmodule Translator do
@moduledoc """
A Tesla Middleware implementation, translating responses to the format
expected by Dayron.

We're also doing JSON decoding of responses here, as the built-in JSON
middleware in Tesla will only decode content-type `application/json`, as
well as raise an error on decoding issues instead of returning the raw
input. Both of these differences break the existing implicit contract, as
implemented by `Dayron.HTTPoisonAdapter`.
"""
def call(env, next, _opts) do
env
|> Tesla.run(next)
|> translate_response()
end

defp translate_response(%Tesla.Env{} = response) do
{:ok, %Dayron.Response{
status_code: response.status,
body: translate_response_body(response.body),
headers: response.headers |> Map.to_list
}
}
end

defp translate_response_body(""), do: nil
defp translate_response_body("ok"), do: %{}
defp translate_response_body(body) do
try do
body |> Poison.decode!(keys: :atoms)
rescue
Poison.SyntaxError -> body
end
end

def translate_error(%Tesla.Error{} = error) do
data = error |> Map.from_struct
{:error, struct(Dayron.ClientError, data)}
end
end

@doc """
Implementation for `Dayron.Adapter.get/3`.
"""
def get(url, headers \\ [], opts \\ []) do
tesla_call(:get, [url, build_options(headers, opts)])
end

@doc """
Implementation for `Dayron.Adapter.post/4`.
"""
def post(url, body, headers \\ [], opts \\ []) do
tesla_call(:post, [url, body, build_options(headers, opts)])
end

@doc """
Implementation for `Dayron.Adapter.patch/4`.
"""
def patch(url, body, headers \\ [], opts \\ []) do
tesla_call(:patch, [url, body, build_options(headers, opts)])
end

@doc """
Implementation for `Dayron.Adapter.delete/3`.
"""
def delete(url, headers \\ [], opts \\ []) do
tesla_call(:delete, [url, build_options(headers, opts)])
end

defp tesla_call(method, args) do
try do
apply(Client, method, args)
rescue
e in Tesla.Error -> Translator.translate_error(e)
end
end

def build_options([], opts), do: build_options(opts)
def build_options(headers, opts) do
build_options([{:headers, Enum.into(headers, %{})} | opts])
end

defp build_options(opts) do
Enum.reduce(opts, [{:opts, build_hackney_options(opts)}], fn
{:headers, value}, options -> [{:headers, value} | options]
{:params, value}, options -> [{:query, value} | options]
_, options -> options
end)
end

defp build_hackney_options(opts) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jwarlander any reason to go low level and building hackney options instead of trying Tesla Dynamic Middlewares to include custom headers and params?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should definitely be possible to allow for custom middlewares; perhaps using application config? I could also see it as an option on the repo itself, something like:

defmodule MyApp.RestRepo do
  use Dayron.Repo, otp_app: :myapp, adapter_opts: [middlewares: [
    {Tesla.Middleware.Headers, %{"Authorization" => "token: " <> token }}
  ]
end 

However, what do you think about me raising a separate pull request for that, on top of the initial working adapter for Tesla?

As far as the current implementation goes, one could imagine just pushing headers into the options in the main client code, and having middleware perform the rest of the option translation.. Strictly speaking though, I'm not sure if it would make a big difference - I see middlewares mostly as an excellent way to support optional / new behavior, and translating the Dayron options to something that's suitable for Tesla's underlying Hackney adapter isn't really optional :)

Copy link
Author

@jwarlander jwarlander Oct 29, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should note though that the response translation ended up as a middleware module because I could find no good way to hook into the Tesla client itself.. then I ended up working outside of the client anyway, and implementing the tesla_call/2 function that could as easily do that part, so I'll admit it's inconsistent ;)

It would probably be pretty nice to split out option handling and response translation both into separate middlewares, put them in their own source files, and clean up the client module. I'll play around with it and see where it goes!

Enum.reduce(opts, [], fn
{:hackney, extra_opts}, hn_opts -> hn_opts ++ extra_opts
{:timeout, value}, hn_opts -> [{:connect_timeout, value} | hn_opts]
{:recv_timeout, value}, hn_opts -> [{:recv_timeout, value} | hn_opts]
{:proxy, value}, hn_opts -> [{:proxy, value} | hn_opts]
{:proxy_auth, value}, hn_opts -> [{:proxy_auth, value} | hn_opts]
{:ssl, value}, hn_opts -> [{:ssl_options, value} | hn_opts]
{:follow_redirect, value}, hn_opts -> [{:follow_redirect, value} | hn_opts]
{:max_redirect, value}, hn_opts -> [{:max_redirect, value} | hn_opts]
#{:stream_to, arg}, hn_opts ->
# [:async, {:stream_to, spawn(module, :transformer, [arg])} | hn_opts]
_other, hn_opts -> hn_opts
end)
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ defmodule Dayron.Mixfile do
[
{:poison, "~> 1.5 or ~> 2.0"},
{:httpoison, "~> 0.8.0"},
{:tesla, "~> 0.5.0", optional: true},
{:crutches, "~> 1.0.0"},
{:credo, "~> 0.3", only: [:dev, :test]},
{:bypass, "~> 0.1", only: :test},
Expand Down
9 changes: 5 additions & 4 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
%{"bunt": {:hex, :bunt, "0.1.5", "c378ea1698232597d3778e4b83234dcea4a60e7c28114b0fe53657a2c0d8885e", [:mix], []},
"bypass": {:hex, :bypass, "0.5.1", "cf3e8a4d376ee1dcd89bf362dfaf1f4bf4a6e19895f52fdc2bafbd8207ce435f", [:mix], [{:plug, "~> 1.0", [hex: :plug, optional: false]}, {:cowboy, "~> 1.0", [hex: :cowboy, optional: false]}]},
"bypass": {:hex, :bypass, "0.5.1", "cf3e8a4d376ee1dcd89bf362dfaf1f4bf4a6e19895f52fdc2bafbd8207ce435f", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: false]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}]},
"certifi": {:hex, :certifi, "0.4.0", "a7966efb868b179023618d29a407548f70c52466bf1849b9e8ebd0e34b7ea11f", [:rebar3], []},
"cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:rebar, :make], [{:cowlib, "~> 1.0.0", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, optional: false]}]},
"cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []},
"credo": {:hex, :credo, "0.3.13", "90d2d2deb9d376bb2a63f81126a320c3920ce65acb1294982ab49a8aacc7d89f", [:mix], [{:bunt, "~> 0.1.4", [hex: :bunt, optional: false]}]},
"crutches": {:hex, :crutches, "1.0.0", "34675f0c88f25a3a6d2a77912194beaece9bf835f9f2f5627027b4a19125aaa6", [:mix], []},
"earmark": {:hex, :earmark, "0.2.1", "ba6d26ceb16106d069b289df66751734802777a3cbb6787026dd800ffeb850f3", [:mix], []},
"ex_doc": {:hex, :ex_doc, "0.11.5", "0dc51cb84f8312162a2313d6c71573a9afa332333d8a332bb12540861b9834db", [:mix], [{:earmark, "~> 0.1.17 or ~> 0.2", [hex: :earmark, optional: true]}]},
"excoveralls": {:hex, :excoveralls, "0.5.4", "1a6e116bcf980da8b7fe33140c1d7e61aa0a4e51951cadbfacc420c12d2b9b8f", [:mix], [{:hackney, ">= 0.12.0", [hex: :hackney, optional: false]}, {:exjsx, "~> 3.0", [hex: :exjsx, optional: false]}]},
"excoveralls": {:hex, :excoveralls, "0.5.4", "1a6e116bcf980da8b7fe33140c1d7e61aa0a4e51951cadbfacc420c12d2b9b8f", [:mix], [{:exjsx, "~> 3.0", [hex: :exjsx, optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, optional: false]}]},
"exjsx": {:hex, :exjsx, "3.2.0", "7136cc739ace295fc74c378f33699e5145bead4fdc1b4799822d0287489136fb", [:mix], [{:jsx, "~> 2.6.2", [hex: :jsx, optional: false]}]},
"hackney": {:hex, :hackney, "1.6.0", "8d1e9440c9edf23bf5e5e2fe0c71de03eb265103b72901337394c840eec679ac", [:rebar3], [{:ssl_verify_fun, "1.1.0", [hex: :ssl_verify_fun, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:idna, "1.2.0", [hex: :idna, optional: false]}, {:certifi, "0.4.0", [hex: :certifi, optional: false]}]},
"hackney": {:hex, :hackney, "1.6.0", "8d1e9440c9edf23bf5e5e2fe0c71de03eb265103b72901337394c840eec679ac", [:rebar3], [{:certifi, "0.4.0", [hex: :certifi, optional: false]}, {:idna, "1.2.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.0", [hex: :ssl_verify_fun, optional: false]}]},
"httpoison": {:hex, :httpoison, "0.8.3", "b675a3fdc839a0b8d7a285c6b3747d6d596ae70b6ccb762233a990d7289ccae4", [:mix], [{:hackney, "~> 1.6.0", [hex: :hackney, optional: false]}]},
"idna": {:hex, :idna, "1.2.0", "ac62ee99da068f43c50dc69acf700e03a62a348360126260e87f2b54eced86b2", [:rebar3], []},
"inch_ex": {:hex, :inch_ex, "0.5.1", "c1c18966c935944cbb2d273796b36e44fab3c54fd59f906ff026a686205b4e14", [:mix], [{:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]},
Expand All @@ -19,4 +19,5 @@
"plug": {:hex, :plug, "1.1.4", "2eee0e85ad420db96e075b3191d3764d6fff61422b101dc5b02e9cce99cacfc7", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}]},
"poison": {:hex, :poison, "1.5.2", "560bdfb7449e3ddd23a096929fb9fc2122f709bcc758b2d5d5a5c7d0ea848910", [:mix], []},
"ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0", "edee20847c42e379bf91261db474ffbe373f8acb56e9079acb6038d4e0bf414f", [:rebar, :make], []}}
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0", "edee20847c42e379bf91261db474ffbe373f8acb56e9079acb6038d4e0bf414f", [:rebar, :make], []},
"tesla": {:hex, :tesla, "0.5.2", "614297046c638b8c1695e8b6e63e6bffd107c07a67e4cb8cc4bb46598be735f1", [:mix], [{:exjsx, ">= 0.1.0", [hex: :exjsx, optional: true]}, {:hackney, "~> 1.6.0", [hex: :hackney, optional: true]}, {:ibrowse, "~> 4.2", [hex: :ibrowse, optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, optional: true]}]}}
161 changes: 161 additions & 0 deletions test/lib/dayron/adapters/tesla_adapter_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
defmodule Dayron.TeslaAdapterTest do
use ExUnit.Case, async: true
alias Dayron.TeslaAdapter

setup do
bypass = Bypass.open
{:ok, bypass: bypass, api_url: "http://localhost:#{bypass.port}"}
end

test "returns a decoded body for a valid get request", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources/id" == conn.request_path
assert "GET" == conn.method
Plug.Conn.resp(conn, 200, ~s<{"name": "Full Name", "address":{"street": "Elm Street", "zipcode": "88888"}}>)
end
response = TeslaAdapter.get("#{api_url}/resources/id")
assert {:ok, %Dayron.Response{status_code: 200, body: body}} = response
assert body[:name] == "Full Name"
assert body[:address] == %{street: "Elm Street", zipcode: "88888"}
end

test "handles response body 'ok'", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources/id" == conn.request_path
assert "GET" == conn.method
Plug.Conn.resp(conn, 200, "ok")
end
response = TeslaAdapter.get("#{api_url}/resources/id")
assert {:ok, %Dayron.Response{status_code: 200, body: body}} = response
assert body == %{}
end

test "handles invalid json body", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources/id" == conn.request_path
assert "GET" == conn.method
Plug.Conn.resp(conn, 200, "{invalid_json=1}")
end
response = TeslaAdapter.get("#{api_url}/resources/id")
assert {:ok, %Dayron.Response{status_code: 200, body: body}} = response
assert body == "{invalid_json=1}"
end

test "returns a decoded body for a response list", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources" == conn.request_path
assert "GET" == conn.method
Plug.Conn.resp(conn, 200, ~s<[{"name": "First Resource"}, {"name": "Second Resource"}]>)
end
response = TeslaAdapter.get("#{api_url}/resources")
assert {:ok, %Dayron.Response{status_code: 200, body: body}} = response
[first, second | _t] = body
assert first[:name] == "First Resource"
assert second[:name] == "Second Resource"
end

test "accepts query parameters and headers", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources" == conn.request_path
assert "q=qu+ery&page=2" == conn.query_string
assert [{"accept", "application/json"} | _] = conn.req_headers
assert "GET" == conn.method
Plug.Conn.resp(conn, 200, "")
end
response = TeslaAdapter.get("#{api_url}/resources", [{"accept", "application/json"}], [params: [{:q, "qu ery"}, {:page, 2}]])
assert {:ok, %Dayron.Response{status_code: 200, body: _}} = response
end

test "accepts custom headers", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources/id" == conn.request_path
assert {"accesstoken", "token"} in conn.req_headers
assert "GET" == conn.method
Plug.Conn.resp(conn, 200, "")
end
response = TeslaAdapter.get("#{api_url}/resources/id", [accesstoken: "token"])
assert {:ok, %Dayron.Response{status_code: 200, body: _}} = response
end

test "returns a 404 response", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources/invalid" == conn.request_path
assert "GET" == conn.method
Plug.Conn.resp(conn, 404, "")
end
response = TeslaAdapter.get("#{api_url}/resources/invalid")
assert {:ok, %Dayron.Response{status_code: 404, body: _}} = response
end

test "returns a 500 error response", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources/server-error" == conn.request_path
assert "GET" == conn.method
Plug.Conn.resp(conn, 500, "")
end
response = TeslaAdapter.get("#{api_url}/resources/server-error")
assert {:ok, %Dayron.Response{status_code: 500, body: _}} = response
end

test "returns an error for invalid server" do
response = TeslaAdapter.get("http://localhost:0001/resources/error")
assert {:error, %Dayron.ClientError{reason: :econnrefused}} = response
end

test "returns a decoded body for a valid post request", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources" == conn.request_path
assert [{"accept", "application/json"} | _] = conn.req_headers
assert "POST" == conn.method
Plug.Conn.resp(conn, 201, ~s<{"name": "Full Name", "age": 30}>)
end
response = TeslaAdapter.post("#{api_url}/resources", %{name: "Full Name", age: 30}, [{"accept", "application/json"}])
assert {:ok, %Dayron.Response{status_code: 201, body: body}} = response
assert body[:name] == "Full Name"
assert body[:age] == 30
end

test "returns a decoded body for a valid patch request", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources/id" == conn.request_path
assert [{"accept", "application/json"} | _] = conn.req_headers
assert "PATCH" == conn.method
Plug.Conn.resp(conn, 200, ~s<{"name": "Full Name", "age": 30}>)
end
response = TeslaAdapter.patch("#{api_url}/resources/id", %{name: "Full Name", age: 30}, [{"accept", "application/json"}])
assert {:ok, %Dayron.Response{status_code: 200, body: body}} = response
assert body[:name] == "Full Name"
assert body[:age] == 30
end

test "returns an empty body for a valid delete request", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
assert "/resources/id" == conn.request_path
assert [{"content-type", "application/json"}|_] = conn.req_headers
assert "DELETE" == conn.method
Plug.Conn.resp(conn, 204, "")
end
response = TeslaAdapter.delete("#{api_url}/resources/id", [{"content-type", "application/json"}])
assert {:ok, %Dayron.Response{status_code: 204, body: nil}} = response
end

test "passing a custom hackney option works", %{bypass: bypass, api_url: api_url} do
Bypass.expect bypass, fn conn ->
case conn.request_path do
"/old" ->
conn
|> Plug.Conn.put_resp_header("location", "/new")
|> Plug.Conn.resp(301, "You are being redirected.")
|> Plug.Conn.halt
"/new" ->
Plug.Conn.resp(conn, 200, "bar")
end
end
response = TeslaAdapter.post("#{api_url}/old", "foo", [], [
{:follow_redirect, true},
{:hackney, [{:force_redirect, true}]}
])
assert {:ok, %Dayron.Response{status_code: 200, body: body}} = response
assert body == "bar"
end
end