diff --git a/.travis.yml b/.travis.yml index 10040d4..ef44c1d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: elixir elixir: - - 1.2.3 + - 1.3.4 otp_release: - 18.2.1 env: diff --git a/README.md b/README.md index f6153d6..a44dd88 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib/dayron/adapters/tesla_adapter.ex b/lib/dayron/adapters/tesla_adapter.ex new file mode 100644 index 0000000..3e5d348 --- /dev/null +++ b/lib/dayron/adapters/tesla_adapter.ex @@ -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 + 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 diff --git a/mix.exs b/mix.exs index 55a6290..4b5a609 100644 --- a/mix.exs +++ b/mix.exs @@ -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}, diff --git a/mix.lock b/mix.lock index c589b7e..4ef92c1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{"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], []}, @@ -7,9 +7,9 @@ "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]}]}, @@ -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]}]}} diff --git a/test/lib/dayron/adapters/tesla_adapter_test.exs b/test/lib/dayron/adapters/tesla_adapter_test.exs new file mode 100644 index 0000000..66c7313 --- /dev/null +++ b/test/lib/dayron/adapters/tesla_adapter_test.exs @@ -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