diff --git a/lib/maverick.ex b/lib/maverick.ex index e5159c8..59bb5ec 100644 --- a/lib/maverick.ex +++ b/lib/maverick.ex @@ -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 @@ -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 diff --git a/lib/maverick/api/generator.ex b/lib/maverick/api/generator.ex index 019faf4..f85af8c 100644 --- a/lib/maverick/api/generator.ex +++ b/lib/maverick/api/generator.ex @@ -59,29 +59,6 @@ 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 @@ -89,10 +66,4 @@ defmodule Maverick.Api.Generator do 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 diff --git a/lib/maverick/exception.ex b/lib/maverick/exception.ex index 351a5b4..fc4784a 100644 --- a/lib/maverick/exception.ex +++ b/lib/maverick/exception.ex @@ -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 diff --git a/lib/maverick/response.ex b/lib/maverick/response.ex new file mode 100644 index 0000000..9b094cc --- /dev/null +++ b/lib/maverick/response.ex @@ -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 diff --git a/test/maverick/api_test.exs b/test/maverick/api_test.exs index aa129e9..f6e32c9 100644 --- a/test/maverick/api_test.exs +++ b/test/maverick/api_test.exs @@ -1,5 +1,7 @@ defmodule Maverick.ApiTest do - use ExUnit.Case + use ExUnit.Case, async: true + + import Maverick.Test.Helpers @host "http://localhost:4000" @@ -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 diff --git a/test/maverick/exception_test.exs b/test/maverick/exception_test.exs index 8e3e033..e4cbe3c 100644 --- a/test/maverick/exception_test.exs +++ b/test/maverick/exception_test.exs @@ -1,6 +1,8 @@ defmodule Maverick.ExceptionTest do use ExUnit.Case + import Maverick.Test.Helpers + @host "http://localhost:4000" @headers [{"content-type", "application/json"}] @@ -52,7 +54,9 @@ 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) @@ -60,6 +64,12 @@ defmodule Maverick.ExceptionTest do 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 diff --git a/test/maverick/response_test.exs b/test/maverick/response_test.exs new file mode 100644 index 0000000..554fb19 --- /dev/null +++ b/test/maverick/response_test.exs @@ -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 diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..fff70ec --- /dev/null +++ b/test/support/conn_case.ex @@ -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