Skip to content

Commit

Permalink
Merge pull request #30 from inaka/flavio.20.insert-action
Browse files Browse the repository at this point in the history
[#20] insert/insert! actions, some refactoring
  • Loading branch information
alemata committed May 10, 2016
2 parents 7c3f01e + 13c4850 commit 359e330
Show file tree
Hide file tree
Showing 10 changed files with 333 additions and 89 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions lib/dayron/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand All @@ -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
18 changes: 15 additions & 3 deletions lib/dayron/adapters/httpoison_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
17 changes: 10 additions & 7 deletions lib/dayron/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions lib/dayron/exceptions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
138 changes: 95 additions & 43 deletions lib/dayron/repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"}
"""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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} ->
Expand All @@ -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
Expand Down
Loading

0 comments on commit 359e330

Please sign in to comment.