defmodule Camarero do
@moduledoc "README.md" |> File.read!() |> String.split("\n") |> Enum.drop(2) |> Enum.join("\n")
require Plug.Router
require Logger
alias Camarero.Catering.Routes
@allowed_methods ~w|get post put delete|a
defmacro __using__(opts \\ []) do
env = __CALLER__
into = Keyword.get(opts, :into, {:%{}, [], []})
deep = Keyword.get(opts, :deep, false)
response_as = Keyword.get(opts, :response_as, :map)
scaffold = Keyword.get(opts, :scaffold, :full)
methods =
opts[:methods]
|> Macro.expand(__CALLER__)
|> case do
nil -> [:get]
method when method in @allowed_methods -> [method]
list when is_list(list) -> list
end
|> Enum.map(&(&1 |> to_string() |> String.downcase() |> String.to_existing_atom()))
|> Enum.filter(&(&1 in @allowed_methods))
[
quote(generated: true, location: :keep, do: @compile({:autoload, true})),
quote(generated: true, location: :keep, do: @after_compile({Camarero, :handler!})),
quote(
generated: true,
location: :keep,
do:
@handler_fq_name(
Keyword.get(
unquote(opts),
:as,
Module.concat([__MODULE__, "Camarero"])
)
)
),
quote(
generated: true,
location: :keep,
do:
defstruct(
handler_fq_name: @handler_fq_name,
methods: unquote(methods),
response_as: unquote(response_as),
scaffold: unquote(scaffold),
__env__: unquote(Macro.escape(env))
)
),
case scaffold do
:full ->
quote(
generated: true,
location: :keep,
do: use(Camarero.Plato, into: unquote(into), deep: unquote(deep))
)
:access ->
quote(
generated: true,
location: :keep,
do: use(Camarero.Tapas, into: unquote(into))
)
:none ->
[]
end
]
end
# idea by Dave Thomas https://twitter.com/pragdave/status/1077775018942185472
@doc false
@spec handler!(env :: nil | Macro.Env.t(), _bytecode :: nil | binary()) :: {atom(), atom()}
def handler!(env, _bytecode) do
fq_name = struct(env.module).handler_fq_name
handler_name = Module.concat(fq_name, Handler)
endpoint_name = Module.concat(fq_name, Endpoint)
# ? FIXME THIS IS AN UGLY HACK UNTIL I WILL FIND THE PROPER SOLUTION
#! <UGLY HACK>
Code.compiler_options(ignore_module_conflict: true)
rehandler!(handler_name, endpoint_name, env)
Code.compiler_options(ignore_module_conflict: false)
#! </UGLY HACK>
{handler_name, endpoint_name}
end
defp rehandler!(handler_name, endpoint_name, env) do
handler_ast = handler_ast()
remodule!(handler_name, handler_ast, env)
unless match?({:module, ^handler_name}, Code.ensure_compiled(handler_name)),
do: raise(CompileError, message: "Generator conflict")
endpoint_ast = endpoint_ast(handler_name)
remodule!(endpoint_name, endpoint_ast, env)
Logger.info(
"[🕷️] handler and endpoint created successfully",
handler: handler_name,
endpoint: endpoint_name
)
rescue
err in [CompileError, UndefinedFunctionError] ->
waiting = round(:rand.uniform() * 100)
Logger.debug(fn ->
"Deferring creation of #{env.module} for #{waiting} ms (#{inspect(err.__struct__)})"
end)
Process.sleep(waiting)
rehandler!(handler_name, endpoint_name, env)
end
@spec remodule!(atom(), any(), Macro.Env.t()) :: {:module, module(), binary(), term()}
defp remodule!(name, ast, env) do
if match?({:module, ^name}, Code.ensure_compiled(name)) do
:code.purge(name)
:code.delete(name)
end
Module.create(
name,
quote(generated: true, location: :keep, do: unquote(Macro.expand(ast, env))),
Macro.Env.location(env)
)
end
@spec handler_wrapper(method :: atom(), endpoint :: binary(), block :: any()) :: any()
defp handler_wrapper(method, endpoint, block) when method in @allowed_methods do
path = endpoint
route = Plug.Router.__route__(method, path, true, [])
{conn, method, match, params, guards, private, assigns} =
case route do
{conn, method, match, _post_match, params, _host_match, guards, private, assigns} ->
{conn, method, match, params, guards, private, assigns}
{conn, method, match, params, _host, guards, private, assigns} ->
{conn, method, match, params, guards, private, assigns}
end
quote generated: true,
location: :keep do
defp(
do_match(unquote(conn), unquote(method), unquote(match), _)
when unquote(guards)
) do
unquote(private)
unquote(assigns)
merge_params = fn
%Plug.Conn.Unfetched{} -> unquote({:%{}, [], params})
fetched -> Map.merge(fetched, unquote({:%{}, [], params}))
end
conn = update_in(unquote(conn).params(), merge_params)
conn = update_in(conn.path_params(), merge_params)
Plug.Router.__put_route__(conn, unquote(path), fn conn, opts -> unquote(block) end)
end
end
end
# credo:disable-for-lines:160
@spec handler_ast() :: term()
defp handler_ast() do
root = ("/" <> (:camarero |> Application.get_env(:root, ""))) |> String.trim("/")
items =
cond do
Enum.find(Process.registered(), &(&1 == Routes)) -> Map.values(Routes.state())
on_duty() -> Application.get_env(:camarero, :carta, [])
true -> []
end
send_resp_block =
quote generated: true,
location: :keep do
defp send_resp_and_envio(conn, status, what) do
Camarero.Spitter.spit(:all, %{conn: conn, status: status, what: what})
conn
|> put_resp_content_type("application/json")
|> send_resp(status, what)
end
end
{routes, ast} =
items
|> Enum.filter(&match?(mod when is_atom(mod), &1))
|> Enum.filter(&match?({:module, ^&1}, Code.ensure_compiled(&1)))
|> Enum.sort_by(&(&1 |> apply(:plato_route, []) |> String.length()), &<=/2)
|> Enum.reduce(
{[], []},
fn module, {routes, ast} ->
endpoint = Enum.join([root, module |> apply(:plato_route, []) |> String.trim("/")], "/")
{routes, ast} =
if Enum.find(struct(module).methods, &(&1 == :get)) do
get_all_block =
quote generated: true,
location: :keep do
{values, status} =
case unquote(module).plato_all() do
{values, status} -> {values, status}
values -> {values, 200}
end
send_resp_and_envio(
conn,
status,
Jason.encode!(
case struct(unquote(module)).response_as do
:value -> values
:map -> %{key: "★", value: values}
_ -> values
end
)
)
end
get_all = handler_wrapper(:get, endpoint, get_all_block)
param = Macro.var(:param, nil)
get_param_block =
quote(
generated: true,
location: :keep,
do: response!(conn, unquote(module), unquote(param))
)
get_param =
handler_wrapper(:get, Enum.join([endpoint, ":param"], "/"), get_param_block)
{[{:get, endpoint, module} | routes], [get_all, get_param | ast]}
else
{routes, ast}
end
{routes, ast} =
if Enum.find(struct(module).methods, &(&1 == :post)) do
post_block =
quote generated: true,
location: :keep do
case unquote(module).reshape(conn.params,
headers: conn.req_headers,
cookies: conn.cookies
) do
%{"key" => key, "value" => value} ->
{value, status} =
case unquote(module).plato_put(key, value) do
{value, status} -> {value, status}
:ok -> {"", 201}
value -> {value, 201}
end
send_resp_and_envio(conn, status, value)
payload ->
send_resp_and_envio(
conn,
412,
Jason.encode!(
%{
errors: ["JSON object with both “key” and “value” keys is required"],
payload: payload
},
[]
)
)
end
end
post_all = handler_wrapper(:post, endpoint, post_block)
{[{:post, endpoint, module} | routes], [post_all | ast]}
else
{routes, ast}
end
{routes, ast} =
if Enum.find(struct(module).methods, &(&1 == :put)) do
put_block =
quote generated: true,
location: :keep do
case unquote(module).reshape(conn.params,
headers: conn.req_headers,
cookies: conn.cookies
) do
%{"param" => key, "value" => value} ->
{value, status} =
case unquote(module).plato_put(key, value) do
{value, status} -> {value, status}
:ok -> {"", 200}
value -> {value, 200}
end
send_resp_and_envio(conn, status, value)
payload ->
send_resp_and_envio(
conn,
412,
Jason.encode!(
%{
errors: ["JSON object with “value” key is required"],
payload: payload
},
[]
)
)
end
end
put_param = handler_wrapper(:put, Enum.join([endpoint, ":param"], "/"), put_block)
{[{:put, endpoint, module} | routes], [put_param | ast]}
else
{routes, ast}
end
{routes, ast} =
if Enum.find(struct(module).methods, &(&1 == :delete)) do
delete_param_block =
quote generated: true,
location: :keep do
case unquote(module).reshape(conn.params,
headers: conn.req_headers,
cookies: conn.cookies
) do
%{"param" => key} ->
# credo:disable-for-lines:6 Credo.Check.Refactor.Nesting
{value, status} =
case unquote(module).plato_delete(key) do
{value, status} -> {value, status}
nil -> {:not_found, 404}
value -> {value, 200}
end
send_resp_and_envio(
conn,
status,
Jason.encode!(%{key: key, value: value})
)
other ->
send_resp_and_envio(
conn,
503,
Jason.encode!(%{
errors: ["You’ve found a bug; please report it :)"],
unexpected_value: inspect(other)
})
)
end
end
delete_param =
handler_wrapper(:delete, Enum.join([endpoint, ":param"], "/"), delete_param_block)
{[{:delete, endpoint, module} | routes], [delete_param | ast]}
else
{routes, ast}
end
{routes, ast}
end
)
full_path = Macro.var(:full_path, nil)
catch_all_block =
quote generated: true,
location: :keep do
[param | path] = Enum.reverse(unquote(full_path))
path = path |> Enum.reverse() |> Enum.join("/")
with {nil, _} <- {Routes.get(path <> "/" <> param), ""},
{nil, _} <- {Routes.get(path), param} do
send_resp_and_envio(
conn,
400,
Jason.encode!(%{
error: "Handler was not found",
path: Enum.join([unquote(root) | unquote(full_path)], "/")
})
)
else
{module, param} ->
Logger.warning(fn ->
~s|Accessing “#{Enum.join(unquote(full_path), "/")}” dynamically. Consider compiling routes.|
end)
response!(conn, module, param)
end
end
catch_dynamic = handler_wrapper(:get, Enum.join([root, "*full_path"], "/"), catch_all_block)
catch_all = Enum.map(@allowed_methods, &handler_wrapper(&1, "/*full_path", catch_all_block))
quote generated: true, location: :keep do
@moduledoc false
require Logger
use Plug.Router
plug(:match)
@root unquote(root)
Module.register_attribute(__MODULE__, :routes, accumulate: true, persist: true)
defp response!(conn, module, param) do
{status, response} =
case module.plato_get(param) do
{:ok, value} -> {200, %{key: param, value: value}}
:error -> {404, %{key: param, error: :not_found}}
{:error, {status, cause}} -> {status, cause}
end
response =
case struct(module).response_as do
:map -> response
:value -> with %{value: value} <- response, do: value
_ -> response
end
send_resp_and_envio(conn, status, Jason.encode!(response))
end
def routes, do: unquote(Macro.escape(routes))
unquote(send_resp_block)
unquote_splicing(Enum.reverse(catch_all ++ [catch_dynamic | ast]))
plug(:dispatch)
end
end
defp endpoint_ast(handler) do
quote generated: true,
location: :keep do
@moduledoc false
use Plug.Builder, init_mode: :runtime
plug(Plug.Logger)
plug(Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
json_decoder: Jason
)
plug(unquote(handler))
end
end
defp on_duty do
Application.get_env(:camarero, :otp_app, :camarero) ==
Mix.Project.get()
|> Module.split()
|> hd()
|> Macro.underscore()
|> String.to_existing_atom()
end
end