diff --git a/README.md b/README.md index 12d2ff7..f0049cd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Coverage Status](https://coveralls.io/repos/github/inaka/Dayron/badge.svg?branch=master)](https://coveralls.io/github/inaka/Dayron?branch=master) [![Twitter](https://img.shields.io/badge/twitter-@inaka-blue.svg?style=flat)](http://twitter.com/inaka) -Dayron is a flexible library to interact with resources from REST APIs and map them to models in Elixir. It works _similar_ to [Ecto.Repo](https://github.com/elixir-lang/ecto) but, instead of retrieving data from a database, it has underlying http clients to retrieve data from external HTTP servers. +Dayron is a flexible library to interact with resources from REST APIs and map them to models in Elixir. It works _similar_ of [Ecto.Repo](https://github.com/elixir-lang/ecto) but, instead of retrieving data from a database, it has underlying http clients to retrieve data from external HTTP servers. ## Installation diff --git a/lib/dayron/adapter.ex b/lib/dayron/adapter.ex index 88bae40..ded64c6 100644 --- a/lib/dayron/adapter.ex +++ b/lib/dayron/adapter.ex @@ -20,6 +20,7 @@ defmodule Dayron.Adapter do alias HTTPoison.Error @type headers :: [{binary, binary}] | %{binary => binary} + @type body :: struct @type response :: {:ok, Response.t} | {:error, Error.t} @doc """ @@ -46,4 +47,35 @@ defmodule Dayron.Adapter do Check the adapter implementations for default values. """ @callback get(binary, headers, Keyword.t) :: response + + @doc """ + Issues a POST 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 post(binary, body, headers, Keyword.t) :: response end diff --git a/lib/dayron/adapters/httpoison_adapter.ex b/lib/dayron/adapters/httpoison_adapter.ex index 2bb6cd1..53bb8f1 100644 --- a/lib/dayron/adapters/httpoison_adapter.ex +++ b/lib/dayron/adapters/httpoison_adapter.ex @@ -20,7 +20,10 @@ defmodule Dayron.HTTPoisonAdapter do require Poison use HTTPoison.Base - def process_response_body(_body = ""), do: nil + def process_request_body(body), do: Poison.encode!(body) + + def process_response_body(""), do: nil + def process_response_body("ok"), do: %{} def process_response_body(body) do body |> Poison.decode! |> process_decoded_body @@ -39,9 +42,10 @@ defmodule Dayron.HTTPoisonAdapter do @doc """ Merges headers received as argument with default headers """ - defp process_request_headers(headers) when is_list(headers) do + def process_request_headers(headers) when is_list(headers) do Enum.into(headers, [ - {"Content-Type", "application/json"} + {"Content-Type", "application/json"}, + {"Accept", "application/json"}, ]) end end @@ -53,4 +57,12 @@ defmodule Dayron.HTTPoisonAdapter do Client.start Client.get(url, headers, opts) end + + @doc """ + Implementation for `Dayron.Adapter.post/4`. + """ + def post(url, body, headers \\ [], opts \\ []) do + Client.start + Client.post(url, body, headers, opts) + end end diff --git a/lib/dayron/config.ex b/lib/dayron/config.ex index 8c43238..7af87e7 100644 --- a/lib/dayron/config.ex +++ b/lib/dayron/config.ex @@ -14,15 +14,14 @@ defmodule Dayron.Config do case parse_url(opts[:url] || config[:url]) do {:ok, url} -> config = Keyword.put(config, :url, url) - {:error, :missing_url} -> + {:error, :missing_url} -> raise ArgumentError, "missing :url configuration in " <> "config #{inspect otp_app}, #{inspect repo}" - - {:error, _} -> + {:error, _} -> raise ArgumentError, "invalid URL for :url configuration in " <> "config #{inspect otp_app}, #{inspect repo}" end - + {otp_app, adapter, config} end @@ -54,15 +53,19 @@ defmodule Dayron.Config do Keyword.get(config, :enable_log, true) end - defp parse_url({:system, env}) when is_binary(env) do + @doc """ + Parses the application configuration :url key, accepting system env or a + binary + """ + def parse_url({:system, env}) when is_binary(env) do parse_url(System.get_env(env) || "") end - defp parse_url(url) when is_binary(url) do + def parse_url(url) when is_binary(url) do info = url |> URI.decode() |> URI.parse() if is_nil(info.host), do: {:error, :invalid_url}, else: {:ok, url} end - defp parse_url(_), do: {:error, :missing_url} + def parse_url(_), do: {:error, :missing_url} end diff --git a/lib/dayron/exceptions.ex b/lib/dayron/exceptions.ex index d3c8fb5..c97c33b 100644 --- a/lib/dayron/exceptions.ex +++ b/lib/dayron/exceptions.ex @@ -51,3 +51,23 @@ defmodule Dayron.ClientError do """ end end + + +defmodule Dayron.ValidationError do + @moduledoc """ + Raised at runtime when the response is a 422 unprocessable entity. + """ + defexception [:url, :method, :response] + + def message(%{url: url, method: method, response: response}) do + """ + validation error in request: + + #{method} #{url} + + * Response (422): + + #{inspect response} + """ + end +end diff --git a/lib/dayron/repo.ex b/lib/dayron/repo.ex index 42b3e02..058951d 100644 --- a/lib/dayron/repo.ex +++ b/lib/dayron/repo.ex @@ -9,13 +9,13 @@ defmodule Dayron.Repo do The `:otp_app` should point to an OTP application that has the repository configuration. For example, the repository: - defmodule MyApp.Dayron do + defmodule MyApp.RestRepo do use Dayron.Repo, otp_app: :my_app end Could be configured with: - config :my_app, MyApp.Dayron, + config :my_app, MyApp.RestRepo, url: "https://api.example.com", headers: [access_token: "token"] @@ -29,7 +29,7 @@ defmodule Dayron.Repo do URLs also support `{:system, "KEY"}` to be given, telling Dayron to load the configuration from the system environment instead: - config :my_app, Dayron, + config :my_app, MyApp.RestRepo, url: {:system, "API_URL"} """ @@ -63,19 +63,19 @@ defmodule Dayron.Repo do Repo.all(@adapter, model, opts, @config) end - def all!(model, opts \\ []) do - Repo.all!(@adapter, model, opts, @config) + def insert(model, data, opts \\ []) do + Repo.insert(@adapter, model, data, opts, @config) end - # TBD - def insert(model, opts \\ []), do: nil + def insert!(model, data, opts \\ []) do + Repo.insert!(@adapter, model, data, opts, @config) + end + # TBD def update(model, opts \\ []), do: nil def delete(model, opts \\ []), do: nil - def insert!(model, opts \\ []), do: nil - def update!(model, opts \\ []), do: nil def delete!(model, opts \\ []), do: nil @@ -89,21 +89,21 @@ defmodule Dayron.Repo do Returns `nil` if no result was found or server reponds with an error. Returns a model struct with response values if valid. - Options are sent directly to the selected adapter. See Dayron.Adapter.get/3 + Options are sent directly to the selected adapter. See `Dayron.Adapter.get/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. """ def get(_module, _id, _opts \\ []) do raise @cannot_call_directly_error end @doc """ - Similar to `get/3` but raises excaptions for known errors: - - ## Exceptions - * `Dayron.NoResultsError` - if server responds with a 404 not found. - * `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. + Similar to `get/3` but raises `Dayron.NoResultsError` if no resource is + returned in the server response. """ def get!(_module, _id, _opts \\ []) do raise @cannot_call_directly_error @@ -116,45 +116,65 @@ defmodule Dayron.Repo do Returns an empty list if no result was found or server reponds with an error. Returns a list of model structs if response is valid. - Options are sent directly to the selected adapter. See Dayron.Adapter.get/3 + Options are sent directly to the selected adapter. See `Dayron.Adapter.get/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. """ def all(_module, _opts \\ []) do raise @cannot_call_directly_error end @doc """ - Similar to `all/2` but raises excaptions for known errors: + 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. + + Options are sent directly to the selected adapter. + See Dayron.Adapter.insert/3 for avaliable options. - ## Exceptions - * `Dayron.NoResultsError` - if server responds with a 404 not found. + ## 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.insert %User{name: "Dayse"} do + {:ok, model} -> # Inserted with success + {:error, changeset} -> # Something went wrong + end + """ - def all!(_module, _opts \\ []) do + def insert(_module, _data, _opts \\ []) do raise @cannot_call_directly_error end - @doc false - def get(adapter, model, id, opts, config) do - {_, response} = get_response(adapter, model, [id: id], opts, config) - case response do - %HTTPoison.Response{status_code: 200, body: body} -> - Model.from_json(model, body) - %HTTPoison.Response{status_code: code} when code >= 300 -> nil - %HTTPoison.Error{reason: _reason} -> nil - end + @doc """ + Similar to `insert/3` but raises a `Dayron.ValidationError` if server + responds with a 422 unprocessable entity. + """ + def insert!(_module, _data, _opts \\ []) do + raise @cannot_call_directly_error end @doc false - def get!(adapter, model, id, opts, config) do + def get(adapter, model, id, opts, config) do {url, response} = get_response(adapter, model, [id: id], opts, config) case response do %HTTPoison.Response{status_code: 200, body: body} -> Model.from_json(model, body) - %HTTPoison.Response{status_code: 404} -> - raise Dayron.NoResultsError, method: "GET", url: url + %HTTPoison.Response{status_code: code} when code >= 300 and code < 500 -> + nil %HTTPoison.Response{status_code: 500, body: body} -> raise Dayron.ServerError, method: "GET", url: url, body: body %HTTPoison.Error{reason: reason} -> :ok @@ -163,18 +183,17 @@ defmodule Dayron.Repo do end @doc false - def all(adapter, model, opts, config) do - {_, response} = get_response(adapter, model, [], opts, config) - case response do - %HTTPoison.Response{status_code: 200, body: body} -> - Model.from_json_list(model, body) - %HTTPoison.Response{status_code: code} when code >= 300 -> [] - %HTTPoison.Error{} -> [] + def get!(adapter, model, id, opts, config) do + case get(adapter, model, id, opts, config) do + nil -> + url = Config.get_request_url(config, model, [id: id]) + raise Dayron.NoResultsError, method: "GET", url: url + model -> model end end @doc false - def all!(adapter, model, opts, config) do + def all(adapter, model, opts, config) do {url, response} = get_response(adapter, model, [], opts, config) case response do %HTTPoison.Response{status_code: 200, body: body} -> @@ -186,12 +205,45 @@ defmodule Dayron.Repo do end end + @doc false + def insert(adapter, model, data, opts, config) do + {url, response} = post_response(adapter, model, data, opts, config) + case response do + %HTTPoison.Response{status_code: 201, body: body} -> + {:ok, Model.from_json(model, body)} + %HTTPoison.Response{status_code: 422, body: body} -> + {:error, %{method: "POST", url: url, response: body}} + %HTTPoison.Response{status_code: code, body: body} when code >= 500 -> + raise Dayron.ServerError, method: "POST", url: url, body: body + %HTTPoison.Error{reason: reason} -> + raise Dayron.ClientError, method: "POST", url: url, reason: reason + end + end + + def insert!(adapter, model, data, opts, config) do + case insert(adapter, model, data, opts, config) do + {:ok, model} -> {:ok, model} + {:error, 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, headers, request_opts, response) + ResponseLogger.log("GET", url, response) + end + {url, response} + end + + defp post_response(adapter, model, data, request_opts, config) do + url = Config.get_request_url(config, model, []) + headers = Config.get_headers(config) + {_, response} = adapter.post(url, data, headers, request_opts) + if Config.log_responses?(config) do + request_details = [body: model] + ResponseLogger.log("POST", url, response, request_details) end {url, response} end diff --git a/lib/dayron/response_logger.ex b/lib/dayron/response_logger.ex index 50102bf..e926963 100644 --- a/lib/dayron/response_logger.ex +++ b/lib/dayron/response_logger.ex @@ -5,25 +5,41 @@ defmodule Dayron.ResponseLogger do require HTTPoison require Logger + @doc """ + Logs a debug or error message based on response code. + """ + def log(method, url, response, req_details \\ []) do + do_log(method, url, response, req_details) + end + @doc """ Logs a debug message for response codes between 200-399. """ - def log(method, url, _headers, _opts, %HTTPoison.Response{status_code: code}) when code < 400 do + def do_log(method, url, %HTTPoison.Response{status_code: code}, req_details) when code < 400 do Logger.debug [method, ?\s, url, ?\s, "-> #{code}"] + log_request_details :debug, req_details end @doc """ Logs an error message for error response codes, or greater than 400. """ - def log(method, url, _headers, _opts, %HTTPoison.Response{status_code: code}) do + def do_log(method, url, %HTTPoison.Response{status_code: code}, req_details) do Logger.error [method, ?\s, url, ?\s, "-> #{code}"] + log_request_details :debug, req_details end @doc """ Logs an error message for response error/exception. """ - def log(method, url, _headers, _opts, %HTTPoison.Error{reason: reason}) do + def do_log(method, url, %HTTPoison.Error{reason: reason}, req_details) do Logger.error [method, ?\s, url, ?\s, "-> #{reason}"] + log_request_details :error, req_details + end + + defp log_request_details(level, req_details) do + if Enum.any?(req_details) do + Logger.log(level, "Request: \n #{inspect req_details, pretty: true}") + end end end diff --git a/test/lib/dayron/adapters/httpoison_adapter_test.exs b/test/lib/dayron/adapters/httpoison_adapter_test.exs index a9ac41e..722647b 100644 --- a/test/lib/dayron/adapters/httpoison_adapter_test.exs +++ b/test/lib/dayron/adapters/httpoison_adapter_test.exs @@ -10,7 +10,7 @@ defmodule Dayron.HTTPoisonAdapterTest do 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 [{"content-type", "application/json"} | _] = conn.req_headers + assert [{"accept", "application/json"}, {"content-type", "application/json"} | _] = conn.req_headers assert "GET" == conn.method Plug.Conn.resp(conn, 200, ~s<{"name": "Full Name", "address":{"street": "Elm Street", "zipcode": "88888"}}>) end @@ -23,7 +23,7 @@ defmodule Dayron.HTTPoisonAdapterTest do 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 [{"content-type", "application/json"} | _] = conn.req_headers + assert [{"accept", "application/json"}, {"content-type", "application/json"} | _] = conn.req_headers assert "GET" == conn.method Plug.Conn.resp(conn, 200, ~s<[{"name": "First Resource"}, {"name": "Second Resource"}]>) end @@ -34,11 +34,24 @@ defmodule Dayron.HTTPoisonAdapterTest do assert second[:name] == "Second Resource" 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"}, {"content-type", "application/json"} | _] = conn.req_headers + assert "POST" == conn.method + Plug.Conn.resp(conn, 201, ~s<{"name": "Full Name", "age": 30}>) + end + response = HTTPoisonAdapter.post("#{api_url}/resources", %{name: "Full Name", age: 30}) + assert {:ok, %HTTPoison.Response{status_code: 201, 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 - assert [{"content-type", "application/json"} | _] = conn.req_headers - assert [_a, _b, {"accesstoken", "token"} | _] = conn.req_headers + assert [{"accept", "application/json"}, {"content-type", "application/json"} | _] = conn.req_headers + assert [_a, _b, _c, {"accesstoken", "token"} | _] = conn.req_headers assert "GET" == conn.method Plug.Conn.resp(conn, 200, "") end @@ -50,7 +63,7 @@ defmodule Dayron.HTTPoisonAdapterTest do Bypass.expect bypass, fn conn -> assert "/resources" == conn.request_path assert "q=qu+ery&page=2" == conn.query_string - assert [{"content-type", "application/json"} | _] = conn.req_headers + assert [{"accept", "application/json"}, {"content-type", "application/json"} | _] = conn.req_headers assert "GET" == conn.method Plug.Conn.resp(conn, 200, "") end diff --git a/test/lib/dayron/repo_test.exs b/test/lib/dayron/repo_test.exs index 4254934..887ce20 100644 --- a/test/lib/dayron/repo_test.exs +++ b/test/lib/dayron/repo_test.exs @@ -5,69 +5,88 @@ defmodule Dayron.RepoTest do defmodule MyModel do use Dayron.Model, resource: "resources" - defstruct name: "", age: 0 + defstruct id: nil, name: "", age: 0 end - test "get a valid resource" do + # ================ GET =========================== + test "`get` a valid resource" do body = %{name: "Full Name", age: 30} assert %MyModel{name: "Full Name", age: 30} = TestRepo.get(MyModel, "id", body: body) end - test "get nil for invalid resource" do + test "`get` nil for invalid resource" do assert nil == TestRepo.get(MyModel, "invalid-id") end - test "get nil for server error" do - assert nil == TestRepo.get(MyModel, "server-error") + test "`get` raises an exception on request error" do + msg = ~r/Internal Exception/ + assert_raise Dayron.ServerError, msg, fn -> + TestRepo.get(MyModel, "server-error") + end end - test "get nil for timeout error" do - assert nil == TestRepo.get(MyModel, "timeout-error") + test "`get` raises an exception on timeout error" do + msg = ~r/connect_timeout/ + assert_raise Dayron.ClientError, msg, fn -> + TestRepo.get(MyModel, "timeout-error") + end end - test "get nil for connection error" do - assert nil == TestRepo.get(MyModel, "connection-error") + test "`get` raises an exception on connection error" do + msg = ~r/econnrefused/ + assert_raise Dayron.ClientError, msg, fn -> + TestRepo.get(MyModel, "connection-error") + end end - test "does not accept direct Dayron.Repo.get call" do + test "`get` does not accept direct Dayron.Repo.get call" do msg = ~r/Cannot call Dayron.Repo directly/ assert_raise RuntimeError, msg, fn -> Dayron.Repo.get(MyModel, "id") end end - test "get! a valid resource" do + # ================ GET! =========================== + test "`get!` a valid resource" do body = %{name: "Full Name", age: 30} assert %MyModel{name: "Full Name", age: 30} = TestRepo.get!(MyModel, "id", body: body) end - test "raises an exception for not found resource" do + test "`get!` raises an exception for not found resource" do assert_raise Dayron.NoResultsError, fn -> TestRepo.get!(MyModel, "invalid-id") end end - test "raises an exception on request error" do + test "`get!` raises an exception on request error" do msg = ~r/Internal Exception/ assert_raise Dayron.ServerError, msg, fn -> TestRepo.get!(MyModel, "server-error") end end - test "raises an exception on timeout error" do + test "`get!` raises an exception on timeout error" do msg = ~r/connect_timeout/ assert_raise Dayron.ClientError, msg, fn -> TestRepo.get!(MyModel, "timeout-error") end end - test "raises an exception on connection error" do + test "`get!` raises an exception on connection error" do msg = ~r/econnrefused/ assert_raise Dayron.ClientError, msg, fn -> TestRepo.get!(MyModel, "connection-error") end end + test "`get!` does not accept direct Dayron.Repo.get! call" do + msg = ~r/Cannot call Dayron.Repo directly/ + assert_raise RuntimeError, msg, fn -> + Dayron.Repo.get!(MyModel, "id") + end + end + + # ================ ALL =========================== test "`all` returns a list of valid resources" do body = [ %{name: "First Resource", age: 30}, @@ -83,33 +102,94 @@ defmodule Dayron.RepoTest do assert [] = TestRepo.all(MyModel, params: params) end - test "`all` resturns empty list for server error" do - assert [] == TestRepo.all(MyModel, [error: "server-error"]) + test "`all` raises an exception on request error" do + msg = ~r/Internal Exception/ + assert_raise Dayron.ServerError, msg, fn -> + TestRepo.all(MyModel, [error: "server-error"]) + end + end + + test "`all` raises an exception on connection error" do + msg = ~r/econnrefused/ + assert_raise Dayron.ClientError, msg, fn -> + TestRepo.all(MyModel, [error: "connection-error"]) + end + end + + test "`all` does not accept direct Dayron.Repo.all call" do + msg = ~r/Cannot call Dayron.Repo directly/ + assert_raise RuntimeError, msg, fn -> + Dayron.Repo.all(MyModel) + end + end + + # ================ INSERT =========================== + test "`insert` creates a valid resource from a model" do + data = %{name: "Full Name", age: 30} + {:ok, %MyModel{} = model} = TestRepo.insert(MyModel, data) + assert model.id == "new-model-id" end - test "`all` returns empty list for timeout error" do - assert [] == TestRepo.all(MyModel, [error: "connection-error"]) + test "`insert` fails when creating a resource from an invalid model" do + data = %{name: nil, age: 30} + {:error, %{method: "POST", response: response}} = TestRepo.insert(MyModel, data) + assert response[:error] == "name is required" end - test "`all!` raises an exception on request error" do + test "`insert` raises an exception on request error" do msg = ~r/Internal Exception/ assert_raise Dayron.ServerError, msg, fn -> - TestRepo.all!(MyModel, [error: "server-error"]) + TestRepo.insert(MyModel, %{error: "server-error"}) end end - test "`all!` raises an exception on timeout error" do - msg = ~r/connect_timeout/ + test "`insert` raises an exception on connection error" do + msg = ~r/econnrefused/ assert_raise Dayron.ClientError, msg, fn -> - TestRepo.all!(MyModel, [error: "timeout-error"]) + TestRepo.insert(MyModel, %{error: "connection-error"}) end end - test "`all!` raises an exception on connection error" do + test "`insert` does not accept direct Dayron.Repo.insert call" do + msg = ~r/Cannot call Dayron.Repo directly/ + assert_raise RuntimeError, msg, fn -> + Dayron.Repo.insert(MyModel, %{}) + end + end + + # ================ INSERT! =========================== + test "`insert!` creates a valid resource from a model" do + data = %{name: "Full Name", age: 30} + {:ok, model = %MyModel{}} = TestRepo.insert!(MyModel, data) + assert model.id == "new-model-id" + end + + test "`insert!` raises an exception when creating a resource from an invalid model" do + data = %{name: nil, age: 30} + msg = ~r/validation error/ + assert_raise Dayron.ValidationError, msg, fn -> + TestRepo.insert!(MyModel, data) + end + end + + test "`insert!` raises an exception on request error" do + msg = ~r/Internal Exception/ + assert_raise Dayron.ServerError, msg, fn -> + TestRepo.insert!(MyModel, %{error: "server-error"}) + end + end + + test "`insert!` raises an exception on connection error" do msg = ~r/econnrefused/ assert_raise Dayron.ClientError, msg, fn -> - TestRepo.all!(MyModel, [error: "connection-error"]) + TestRepo.insert!(MyModel, %{error: "connection-error"}) end end + test "`insert!` does not accept direct Dayron.Repo.insert! call" do + msg = ~r/Cannot call Dayron.Repo directly/ + assert_raise RuntimeError, msg, fn -> + Dayron.Repo.insert!(MyModel, %{}) + end + end end diff --git a/test/support/test_repo.exs b/test/support/test_repo.exs index 4c67e01..47258ff 100644 --- a/test/support/test_repo.exs +++ b/test/support/test_repo.exs @@ -35,7 +35,6 @@ defmodule Dayron.TestAdapter do {:ok, %HTTPoison.Response{status_code: 500, body: "Internal Exception..."}} end - def get("http://localhost/resources/connection-error", [], []) do {:error, %HTTPoison.Error{id: nil, reason: :econnrefused}} end @@ -44,6 +43,23 @@ defmodule Dayron.TestAdapter do {:error, %HTTPoison.Error{id: nil, reason: :connect_timeout}} end + def post("http://localhost/resources", %{error: "server-error"}, [], []) do + {:ok, %HTTPoison.Response{status_code: 500, body: "Internal Exception..."}} + end + + def post("http://localhost/resources", %{error: "connection-error"}, [], []) do + {:error, %HTTPoison.Error{id: nil, reason: :econnrefused}} + end + + def post("http://localhost/resources", %{name: nil}, [], []) do + {:ok, %HTTPoison.Response{status_code: 422, body: %{error: "name is required"}}} + end + + def post("http://localhost/resources", model, [], []) do + model = Map.put(model, :id, "new-model-id") + {:ok, %HTTPoison.Response{status_code: 201, body: model}} + end + %HTTPoison.Error{reason: :connect_timeout} end