From d4238acd9364294dae36a27939be20ec61632a87 Mon Sep 17 00:00:00 2001 From: Flavio Granero Date: Tue, 10 May 2016 16:26:49 -0300 Subject: [PATCH] [#21] repo.update action, adapter and httpoison_adapter patch method --- lib/dayron/adapter.ex | 31 +++++ lib/dayron/adapters/httpoison_adapter.ex | 8 ++ lib/dayron/repo.ex | 106 +++++++++++++++--- .../adapters/httpoison_adapter_test.exs | 13 +++ test/lib/dayron/repo_test.exs | 82 ++++++++++++++ test/support/test_repo.exs | 21 +++- 6 files changed, 242 insertions(+), 19 deletions(-) diff --git a/lib/dayron/adapter.ex b/lib/dayron/adapter.ex index ded64c6..de0a41e 100644 --- a/lib/dayron/adapter.ex +++ b/lib/dayron/adapter.ex @@ -78,4 +78,35 @@ defmodule Dayron.Adapter do Check the adapter implementations for default values. """ @callback post(binary, body, headers, Keyword.t) :: response + + @doc """ + Issues a PATCH request to the given url. + + Returns `{:ok, response}` if the request is successful, + `{:error, reason}` otherwise. + + ## Arguments: + * `url` - target url as a binary string or char list + * `body` - request body. Usually a struct deriving `Poison.Encoder` + * `headers` - HTTP headers as an orddict + (e.g., `[{"Accept", "application/json"}]`) + * `options` - Keyword list of options + + ## Options: + * `:timeout` - timeout to establish a connection, in milliseconds. + * `:recv_timeout` - timeout used when receiving a connection. + * `:stream_to` - a PID to stream the response to + * `:proxy` - a proxy to be used for the request; + it can be a regular url or a `{Host, Proxy}` tuple + * `:proxy_auth` - proxy authentication `{User, Password}` tuple + * `:ssl` - SSL options supported by the `ssl` erlang module + * `:follow_redirect` - a boolean that causes redirects to be followed + * `:max_redirect` - the maximum number of redirects to follow + * `:params` - an enumerable consisting of two-item tuples that will be + appended to the url as query string parameters + + Timeouts can be an integer or `:infinity`. + Check the adapter implementations for default values. + """ + @callback patch(binary, body, headers, Keyword.t) :: response end diff --git a/lib/dayron/adapters/httpoison_adapter.ex b/lib/dayron/adapters/httpoison_adapter.ex index 53bb8f1..5c7829a 100644 --- a/lib/dayron/adapters/httpoison_adapter.ex +++ b/lib/dayron/adapters/httpoison_adapter.ex @@ -65,4 +65,12 @@ defmodule Dayron.HTTPoisonAdapter do Client.start Client.post(url, body, headers, opts) end + + @doc """ + Implementation for `Dayron.Adapter.patch/4`. + """ + def patch(url, body, headers \\ [], opts \\ []) do + Client.start + Client.patch(url, body, headers, opts) + end end diff --git a/lib/dayron/repo.ex b/lib/dayron/repo.ex index 058951d..47536bd 100644 --- a/lib/dayron/repo.ex +++ b/lib/dayron/repo.ex @@ -71,12 +71,16 @@ defmodule Dayron.Repo do Repo.insert!(@adapter, model, data, opts, @config) end - # TBD - def update(model, opts \\ []), do: nil + def update(model, id, data, opts \\ []) do + Repo.update(@adapter, model, id, data, opts, @config) + end - def delete(model, opts \\ []), do: nil + def update!(model, id, data, opts \\ []) do + Repo.update!(@adapter, model, id, data, opts, @config) + end - def update!(model, opts \\ []), do: nil + # TBD + def delete(model, opts \\ []), do: nil def delete!(model, opts \\ []), do: nil end @@ -129,15 +133,7 @@ defmodule Dayron.Repo do end @doc """ - Inserts a model or an `Ecto.Changeset`. - - In case a model is given, the model is converted into a changeset - with all model non-virtual fields as part of the changeset. - This conversion is done by calling `Ecto.Changeset.change/2` directly. - - In case a changeset is given, the changes in the changeset are - merged with the model fields, and all of them are sent to the - database. + Inserts a model given a map with resource attributes. Options are sent directly to the selected adapter. See Dayron.Adapter.insert/3 for avaliable options. @@ -149,9 +145,9 @@ defmodule Dayron.Repo do ## Example - case RestRepo.insert %User{name: "Dayse"} do - {:ok, model} -> # Inserted with success - {:error, changeset} -> # Something went wrong + case RestRepo.insert User, %{name: "Dayse"} do + {:ok, model} -> # Inserted with success + {:error, error} -> # Something went wrong end """ @@ -167,6 +163,39 @@ defmodule Dayron.Repo do raise @cannot_call_directly_error end + @doc """ + Updates a model given an id and a map with resource attributes. + + Options are sent directly to the selected adapter. + See Dayron.Adapter.insert/3 for avaliable options. + + ## Possible Exceptions + * `Dayron.ServerError` - if server responds with a 500 internal error. + * `Dayron.ClientError` - for any error detected in client side, such as + timeout or connection errors. + + ## Example + + case RestRepo.update User, "user-id", %{name: "Dayse"} do + {:ok, model} -> # Updated with success + {:error, error} -> # Something went wrong + end + + """ + def update(_module, _id, _data, _opts \\ []) do + raise @cannot_call_directly_error + end + + @doc """ + Similar to `insert/4` but raises: + * `Dayron.NoResultsError` - if server responds with 404 resource not found. + * `Dayron.ValidationError` - if server responds with 422 unprocessable + entity. + """ + def update!(_module, _id, _data, _opts \\ []) do + raise @cannot_call_directly_error + end + @doc false def get(adapter, model, id, opts, config) do {url, response} = get_response(adapter, model, [id: id], opts, config) @@ -220,6 +249,7 @@ defmodule Dayron.Repo do end end + @doc false def insert!(adapter, model, data, opts, config) do case insert(adapter, model, data, opts, config) do {:ok, model} -> {:ok, model} @@ -227,12 +257,41 @@ defmodule Dayron.Repo do end end + @doc false + def update(adapter, model, id, data, opts, config) do + {url, response} = patch_response(adapter, model, [id: id], data, opts, + config) + case response do + %HTTPoison.Response{status_code: 200, body: body} -> + {:ok, Model.from_json(model, body)} + %HTTPoison.Response{status_code: code, body: body} + when code >= 400 and code < 500 -> + {:error, %{method: "PATCH", code: code, url: url, response: body}} + %HTTPoison.Response{status_code: code, body: body} when code >= 500 -> + raise Dayron.ServerError, method: "PATCH", url: url, body: body + %HTTPoison.Error{reason: reason} -> + raise Dayron.ClientError, method: "PATCH", url: url, reason: reason + end + end + + @doc false + def update!(adapter, model, id, data, opts, config) do + case update(adapter, model, id, data, opts, config) do + {:ok, model} -> {:ok, model} + {:error, %{code: 404} = error} -> + raise Dayron.NoResultsError, Map.to_list(error) + {:error, %{code: 422} = error} -> + raise Dayron.ValidationError, Map.to_list(error) + end + end + defp get_response(adapter, model, url_opts, request_opts, config) do url = Config.get_request_url(config, model, url_opts) headers = Config.get_headers(config) {_, response} = adapter.get(url, headers, request_opts) if Config.log_responses?(config) do - ResponseLogger.log("GET", url, response) + request_details = [params: url_opts] + ResponseLogger.log("GET", url, response, request_details) end {url, response} end @@ -242,9 +301,20 @@ defmodule Dayron.Repo do headers = Config.get_headers(config) {_, response} = adapter.post(url, data, headers, request_opts) if Config.log_responses?(config) do - request_details = [body: model] + request_details = [body: data] ResponseLogger.log("POST", url, response, request_details) end {url, response} end + + defp patch_response(adapter, model, url_opts, data, request_opts, config) do + url = Config.get_request_url(config, model, url_opts) + headers = Config.get_headers(config) + {_, response} = adapter.patch(url, data, headers, request_opts) + if Config.log_responses?(config) do + request_details = [body: data] + ResponseLogger.log("PATCH", url, response, request_details) + end + {url, response} + end end diff --git a/test/lib/dayron/adapters/httpoison_adapter_test.exs b/test/lib/dayron/adapters/httpoison_adapter_test.exs index 722647b..82810d7 100644 --- a/test/lib/dayron/adapters/httpoison_adapter_test.exs +++ b/test/lib/dayron/adapters/httpoison_adapter_test.exs @@ -47,6 +47,19 @@ defmodule Dayron.HTTPoisonAdapterTest do 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"}, {"content-type", "application/json"} | _] = conn.req_headers + assert "PATCH" == conn.method + Plug.Conn.resp(conn, 200, ~s<{"name": "Full Name", "age": 30}>) + end + response = HTTPoisonAdapter.patch("#{api_url}/resources/id", %{name: "Full Name", age: 30}) + assert {:ok, %HTTPoison.Response{status_code: 200, body: body}} = response + assert body[:name] == "Full Name" + assert body[:age] == 30 + end + test "accepts custom headers", %{bypass: bypass, api_url: api_url} do Bypass.expect bypass, fn conn -> assert "/resources/id" == conn.request_path diff --git a/test/lib/dayron/repo_test.exs b/test/lib/dayron/repo_test.exs index 887ce20..6bfac5d 100644 --- a/test/lib/dayron/repo_test.exs +++ b/test/lib/dayron/repo_test.exs @@ -192,4 +192,86 @@ defmodule Dayron.RepoTest do Dayron.Repo.insert!(MyModel, %{}) end end + + # ================ UPDATE =========================== + test "`update` a valid resource given model and data" do + data = %{name: "Full Name", age: 30} + {:ok, %MyModel{} = model} = TestRepo.update(MyModel, 'id', data) + assert model.id == "updated-model-id" + end + + test "`update` a valid resource fails when data is invalid" do + data = %{name: nil, age: 30} + {:error, %{method: "PATCH", code: 422, response: response}} = TestRepo.update(MyModel, 'id', data) + assert response[:error] == "name is required" + end + + test "`update` an invalid resource returns an error" do + data = %{name: "Full Name", age: 30} + {:error, %{method: "PATCH", code: 404}} = TestRepo.update(MyModel, 'invalid-id', data) + end + + test "`update` raises an exception on request error" do + msg = ~r/Internal Exception/ + assert_raise Dayron.ServerError, msg, fn -> + TestRepo.update(MyModel, 'id', %{error: "server-error"}) + end + end + + test "`update` raises an exception on connection error" do + msg = ~r/econnrefused/ + assert_raise Dayron.ClientError, msg, fn -> + TestRepo.update(MyModel, 'id', %{error: "connection-error"}) + end + end + + test "`update` does not accept direct Dayron.Repo.update call" do + msg = ~r/Cannot call Dayron.Repo directly/ + assert_raise RuntimeError, msg, fn -> + Dayron.Repo.update(MyModel, 'id', %{}) + end + end + + # ================ INSERT! =========================== + test "`update!` a valid resource given a model and data" do + data = %{name: "Full Name", age: 30} + {:ok, model = %MyModel{}} = TestRepo.update!(MyModel, 'id', data) + assert model.id == "updated-model-id" + end + + test "`update!` raises an exception when data is an invalid model" do + data = %{name: nil, age: 30} + msg = ~r/validation error/ + assert_raise Dayron.ValidationError, msg, fn -> + TestRepo.update!(MyModel, 'id', data) + end + end + + test "`update!` raises an exception when id is invalid" do + data = %{name: "Full Name", age: 30} + assert_raise Dayron.NoResultsError, fn -> + TestRepo.update!(MyModel, 'invalid-id', data) + end + end + + test "`update!` raises an exception on request error" do + msg = ~r/Internal Exception/ + assert_raise Dayron.ServerError, msg, fn -> + TestRepo.update!(MyModel, 'id', %{error: "server-error"}) + end + end + + test "`update!` raises an exception on connection error" do + msg = ~r/econnrefused/ + assert_raise Dayron.ClientError, msg, fn -> + TestRepo.update!(MyModel, 'id', %{error: "connection-error"}) + end + end + + test "`update!` does not accept direct Dayron.Repo.update! call" do + msg = ~r/Cannot call Dayron.Repo directly/ + assert_raise RuntimeError, msg, fn -> + Dayron.Repo.update!(MyModel, 'id', %{}) + end + end end diff --git a/test/support/test_repo.exs b/test/support/test_repo.exs index 47258ff..9acb78a 100644 --- a/test/support/test_repo.exs +++ b/test/support/test_repo.exs @@ -60,7 +60,26 @@ defmodule Dayron.TestAdapter do {:ok, %HTTPoison.Response{status_code: 201, body: model}} end - %HTTPoison.Error{reason: :connect_timeout} + def patch("http://localhost/resources/id", %{name: nil}, [], []) do + {:ok, %HTTPoison.Response{status_code: 422, body: %{error: "name is required"}}} + end + + def patch("http://localhost/resources/id", %{error: "server-error"}, [], []) do + {:ok, %HTTPoison.Response{status_code: 500, body: "Internal Exception..."}} + end + + def patch("http://localhost/resources/id", %{error: "connection-error"}, [], []) do + {:error, %HTTPoison.Error{id: nil, reason: :econnrefused}} + end + + def patch("http://localhost/resources/invalid-id", _, [], []) do + {:ok, %HTTPoison.Response{status_code: 404, body: ""}} + end + + def patch("http://localhost/resources/id", model, [], []) do + model = Map.put(model, :id, "updated-model-id") + {:ok, %HTTPoison.Response{status_code: 200, body: model}} + end end Application.put_env(:dayron, Dayron.TestRepo, [url: "http://localhost", enable_log: false])