Skip to content

Commit

Permalink
Create Maverick.Response protocol and switches response handling to it (
Browse files Browse the repository at this point in the history
#28)

* Create Maverick.Response protocol and switches response handling to it

* Ran formatter
  • Loading branch information
bbalser authored Nov 14, 2021
1 parent d1a117a commit 6b4f90d
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 40 deletions.
16 changes: 10 additions & 6 deletions lib/maverick.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,7 @@ defmodule Maverick do
arg = Maverick.Api.Generator.decode_arg_type(conn, route.args)
response = apply(__MODULE__, route.function, [arg])

Maverick.Api.Generator.wrap_response(
conn,
response,
route.success_code,
route.error_code
)
Maverick.handle_response(response, conn)
end
end
end
Expand Down Expand Up @@ -102,6 +97,15 @@ defmodule Maverick do
[]
end

def handle_response(%Plug.Conn{} = conn, _) do
conn
end

def handle_response(term, conn) do
response = Maverick.Response.handle(term, conn)
handle_response(response, conn)
end

defp parse_http_code(code) when is_integer(code), do: code

defp parse_http_code(code) when is_binary(code) do
Expand Down
29 changes: 0 additions & 29 deletions lib/maverick/api/generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,40 +59,11 @@ defmodule Maverick.Api.Generator do
end
end

def wrap_response(conn, {:ok, headers, response}, success, _error) do
conn
|> Plug.Conn.put_resp_content_type("application/json", nil)
|> add_headers(headers)
|> Plug.Conn.send_resp(success, Jason.encode!(response))
end

def wrap_response(conn, {:error, error_message}, _success, error) do
response =
%{error_code: error, error_message: error_message}
|> Jason.encode!()

conn
|> Plug.Conn.put_resp_content_type("application/json", nil)
|> Plug.Conn.send_resp(error, response)
end

def wrap_response(conn, response, success, _error) do
conn
|> Plug.Conn.put_resp_content_type("application/json", nil)
|> Plug.Conn.send_resp(success, Jason.encode!(response))
end

def decode_arg_type(conn, :conn) do
conn
end

def decode_arg_type(conn, _) do
Map.get(conn, :params)
end

defp add_headers(conn, headers) do
Enum.reduce(headers, conn, fn {key, value}, conn ->
Plug.Conn.put_resp_header(conn, key, value)
end)
end
end
2 changes: 1 addition & 1 deletion lib/maverick/exception.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule Maverick.Exception.Default do
|> Jason.encode!()

conn
|> Plug.Conn.put_resp_content_type("application/json", nil)
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(status, response)
end

Expand Down
45 changes: 45 additions & 0 deletions lib/maverick/response.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defprotocol Maverick.Response do
@fallback_to_any true
def handle(t, conn)
end

defimpl Maverick.Response, for: Any do
def handle(term, %Plug.Conn{private: %{maverick_route: route}} = conn) do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(route.success_code, Jason.encode!(term))
end
end

defimpl Maverick.Response, for: Tuple do
def handle({:ok, term}, conn) do
Maverick.Response.handle(term, conn)
end

def handle({status, headers, term}, conn) do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> add_headers(headers)
|> Plug.Conn.resp(status, Jason.encode!(term))
end

def handle({:error, exception}, conn) when is_exception(exception) do
Maverick.Exception.handle(exception, conn)
end

def handle({:error, error_message}, %Plug.Conn{private: %{maverick_route: route}} = conn) do
response =
%{error_code: route.error_code, error_message: error_message}
|> Jason.encode!()

conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(route.error_code, response)
end

defp add_headers(conn, headers) do
Enum.reduce(headers, conn, fn {key, value}, conn ->
Plug.Conn.put_resp_header(conn, key, value)
end)
end
end
16 changes: 14 additions & 2 deletions test/maverick/api_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
defmodule Maverick.ApiTest do
use ExUnit.Case
use ExUnit.Case, async: true

import Maverick.Test.Helpers

@host "http://localhost:4000"

Expand Down Expand Up @@ -94,12 +96,22 @@ defmodule Maverick.ApiTest do

defp resp_headers({:ok, _status_code, headers, _ref}), do: headers

defp resp_header({:ok, _, headers, _}, key) do
Enum.find(headers, fn {k, _} -> k == key end)
end

defp resp_body({:ok, _status_code, _headers, ref}) do
{:ok, body} = :hackney.body(ref)
Jason.decode!(body)
end

defp resp_content_type(resp) do
{"content-type", "application/json"} in resp_headers(resp)
case resp_header(resp, "content-type") do
nil ->
flunk("Content-type is not set")

{_, content_type} ->
assert response_content_type?(content_type, :json)
end
end
end
14 changes: 12 additions & 2 deletions test/maverick/exception_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule Maverick.ExceptionTest do
use ExUnit.Case

import Maverick.Test.Helpers

@host "http://localhost:4000"
@headers [{"content-type", "application/json"}]

Expand Down Expand Up @@ -52,14 +54,22 @@ defmodule Maverick.ExceptionTest do

defp resp_code({:ok, status_code, _headers, _ref}), do: status_code

defp resp_headers({:ok, _status_code, headers, _ref}), do: headers
defp resp_header({:ok, _, headers, _}, key) do
Enum.find(headers, fn {k, _} -> k == key end)
end

defp resp_body({:ok, _status_code, _headers, ref}) do
{:ok, body} = :hackney.body(ref)
Jason.decode!(body)
end

defp resp_content_type(resp) do
{"content-type", "application/json"} in resp_headers(resp)
case resp_header(resp, "content-type") do
nil ->
flunk("Content-type is not set")

{_, content_type} ->
assert response_content_type?(content_type, :json)
end
end
end
63 changes: 63 additions & 0 deletions test/maverick/response_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule Maverick.ResponseTest do
use Maverick.ConnCase, async: true
use Plug.Test

setup do
route = %Maverick.Route{success_code: 200, error_code: 403}

[
route: route,
conn: conn(:get, "/") |> Plug.Conn.put_private(:maverick_route, route)
]
end

test "a raw map is json encoded with success response", ctx do
response =
Maverick.Response.handle(%{one: 1}, ctx.conn)
|> json_response(200)

assert %{"one" => 1} == response
end

test "a raw string is json encoded with success response", ctx do
response =
Maverick.Response.handle("hello world", ctx.conn)
|> json_response(200)

assert "hello world" == response
end

test "an ok tuple with json encode the term with success response", ctx do
response =
Maverick.Response.handle({:ok, %{one: 1}}, ctx.conn)
|> json_response(200)

assert %{"one" => 1} == response
end

test "a 3 element tuple controle status, headers and response explicitly", ctx do
conn = Maverick.Response.handle({202, [{"key", "value"}], %{one: 1}}, ctx.conn)
response = json_response(conn, 202)

assert %{"one" => 1} == response
assert ["value"] = Plug.Conn.get_resp_header(conn, "key")
end

test "a error tuple json encodes reason with error response", ctx do
response =
Maverick.Response.handle({:error, "bad stuff"}, ctx.conn)
|> json_response(403)

assert %{"error_code" => 403, "error_message" => "bad stuff"} == response
end

test "an error exception tuple triggers Maverick.Exception protocol", ctx do
exception = ArgumentError.exception(message: "argument is bad")

response =
Maverick.Response.handle({:error, exception}, ctx.conn)
|> json_response(500)

assert %{"error_code" => 500, "error_message" => "argument is bad"} == response
end
end
70 changes: 70 additions & 0 deletions test/support/conn_case.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule Maverick.ConnCase do
use ExUnit.CaseTemplate

using do
quote do
import Maverick.Test.Helpers
end
end
end

defmodule Maverick.Test.Helpers do
require ExUnit.Assertions

def response(%Plug.Conn{status: status, resp_body: body}, given) do
given = Plug.Conn.Status.code(given)

if given == status do
body
else
raise "expected response with status #{given}, got: #{status}, with body:\n#{inspect(body)}"
end
end

def json_response(conn, status) do
body = response(conn, status)
_ = response_content_type(conn, :json)

Jason.decode!(body)
end

def response_content_type(conn, format) when is_atom(format) do
case Plug.Conn.get_resp_header(conn, "content-type") do
[] ->
raise "no content-type was set, expected a #{format} response"

[h] ->
if response_content_type?(h, format) do
h
else
raise "expected content-type for #{format}, got: #{inspect(h)}"
end

[_ | _] ->
raise "more than one content-type was set, expected a #{format} response"
end
end

def response_content_type?(header, format) do
case parse_content_type(header) do
{part, subpart} ->
format = Atom.to_string(format)

format in MIME.extensions(part <> "/" <> subpart) or
format == subpart or String.ends_with?(subpart, "+" <> format)

_ ->
false
end
end

defp parse_content_type(header) do
case Plug.Conn.Utils.content_type(header) do
{:ok, part, subpart, _params} ->
{part, subpart}

_ ->
false
end
end
end

0 comments on commit 6b4f90d

Please sign in to comment.