src/nova_handler.erl

-module(nova_handler).
-behaviour(cowboy_middleware).

%% Callbacks
-export([
         execute/2,
         terminate/3
        ]).

-include_lib("kernel/include/logger.hrl").
-include("nova_router.hrl").

-callback init(Req, any()) -> {ok | module(), Req, any()}
                                  | {module(), Req, any(), any()}
                                  when Req::cowboy_req:req().

-callback terminate(any(), map(), any()) -> ok.
-optional_callbacks([terminate/3]).

-spec execute(Req, Env) -> {ok, Req, Env}
                               when Req::cowboy_req:req(), Env::cowboy_middleware:env().
execute(Req, Env = #{cowboy_handler := Handler, arguments := Arguments}) ->
    UseStacktrace = persistent_term:get(nova_use_stacktrace, false),
    try erlang:apply(Handler, init, [Req, Arguments]) of
        {ok, Req2, _State} ->
            Result = terminate(normal, Req2, Handler),
            {ok, Req2, Env#{result => Result}};
        {Mod, Req2, State} ->
            erlang:apply(Mod, upgrade, [Req2, Env, Handler, State]);
        {Mod, Req2, State, Opts} ->
            erlang:apply(Mod, upgrade, [Req2, Env, Handler, State, Opts])
    catch
        Class:Reason:Stacktrace when UseStacktrace == true ->
            Payload = #{status_code => 404,
                        stacktrace => Stacktrace,
                        class => Class,
                        reason => Reason},
            ?LOG_ERROR(#{msg => <<"Controller crashed">>, class => Class, reason => Reason, stacktrace => Stacktrace}),
            render_response(Req#{crash_info => Payload}, maps:remove(cowboy_handler, Env), 404);
        Class:Reason ->
            Payload = #{status_code => 404,
                        class => Class,
                        reason => Reason},
            ?LOG_ERROR(#{msg => <<"Controller crashed">>, class => Class, reason => Reason}),
            render_response(Req#{crash_info => Payload}, maps:remove(cowboy_handler, Env), 404)
    end;
execute(Req, Env = #{module := Module, function := Function}) ->
    %% Ensure that the module exists and have the correct function exported
    UseStacktrace = persistent_term:get(nova_use_stacktrace, false),
    try RetObj = erlang:apply(Module, Function, [Req]),
         call_handler(Module, Function, Req, RetObj, Env, false)
    of
        HandlerReturn ->
            HandlerReturn
    catch
        Class:{Status, Reason} when is_integer(Status) ->
            %% This makes it so that we don't need to fetch the stacktrace
            ?LOG_ERROR(#{msg => <<"Controller threw an exception">>,
                         class => Class,
                         reason => Reason}),
            render_response(Req#{crash_info => Reason}, Env, Status);
        Class:Reason:Stacktrace when UseStacktrace == true ->
            ?LOG_ERROR(#{msg => <<"Controller crashed">>,
                         class => Class,
                         reason => Reason,
                         stacktrace => Stacktrace}),
            terminate(Reason, Req, Module),
            %% Build the payload object
            Payload = #{status_code => 500,
                        stacktrace => Stacktrace,
                        class => Class,
                        reason => Reason},
            render_response(Req#{crash_info => Payload}, Env, 500);
        Class:Reason ->
            ?LOG_ERROR(#{msg => <<"Controller crashed">>,
                         class => Class,
                         reason => Reason}),
            terminate(Reason, Req, Module),
            %% Build the payload object
            Payload = #{status_code => 500,
                        class => Class,
                        reason => Reason},
            render_response(Req#{crash_info => Payload}, Env, 500)
    end.

-spec terminate(any(), Req | undefined, module()) -> ok when Req::cowboy_req:req().
terminate(Reason, Req, Module) ->
    case function_exported(Module, terminate, 3) of
        true ->
            erlang:apply(Module, terminate, [Reason, Req]);
        false ->
            ok
    end.


%%%%%%%%%%%%%%%%%%%%%%%%
%% INTERNAL FUNCTIONS %%
%%%%%%%%%%%%%%%%%%%%%%%%
-spec execute_fallback(Module :: atom(), Req :: cowboy_req:req(), Response :: any(), Env :: map()) ->
          {ok, Req0 :: cowboy_req:req(), Env :: map()}.
execute_fallback(Module, Req, Response, Env) ->
    Attributes = erlang:apply(Module, module_info, [attributes]),
    case proplists:get_value(fallback_controller, Attributes, undefined) of
        undefined ->
            %% No fallback - render a crash-page
            Payload = #{status_code => 500,
                        status => <<"Problems with controller">>,
                        stacktrace => [{nova_handler, execute_fallback, 4}],
                        title => <<"Controller returned unsupported data">>,
                        extra_msg => list_to_binary(io_lib:format("Controller returned unsupported data: ~p",
                                                                  [Response]))},
            render_response(Req#{crash_info => Payload}, Env, 500);
        [FallbackModule] ->
            case function_exported(FallbackModule, resolve, 2) of
                true ->
                    RetObj = erlang:apply(FallbackModule, resolve, [Req, Response]),
                    call_handler(FallbackModule, resolve, Req, RetObj, Env, true);
                _ ->
                    Payload = #{status_code => 500,
                                status => <<"Problems with fallback-controller">>,
                                title => <<"Fallback controller does not have a valid resolve/2 function">>,
                                extra_msg => list_to_binary(io_lib:format("Fallback controller ~s does not have "
                                                                          ++ "a valid resolve/2 function",
                                                                          [FallbackModule]))},
                    render_response(Req#{crash_info => Payload}, Env, 500)
            end
    end.

-spec call_handler(Module :: atom(), Function :: atom(), Req :: cowboy_req:req(),
                   RetObj :: any(), Env :: map(), IsFallbackController :: boolean()) ->
          {ok, Req0 :: cowboy_req:req(), Env :: map()}.
call_handler(Module, Function, Req, RetObj, Env, IsFallbackController) ->
    case nova_handlers:get_handler(element(1, RetObj)) of
        {ok, Callback} ->
            {ok, Req0} = Callback(RetObj, {Module, Function}, Req),
            render_response(Req0, Env);
        {error, not_found} ->
            case IsFallbackController of
                true ->
                    ?LOG_ERROR(#{msg => <<"Controller returned unsupported result">>, controller => Module,
                                 function => Function, return => RetObj}),
                    Payload = #{status_code => 500,
                                status => <<"Controller returned unsupported result">>,
                                title => <<"Error in controller">>,
                                extra_msg => list_to_binary(io_lib:format("Controller ~s:~s/1 returned a " ++
                                                                              "non-valid result ~s",
                                                                          [Module, Function, RetObj]))},
                    render_response(Req#{crash_info => Payload}, Env, 500);
                _ ->
                    execute_fallback(Module, Req, RetObj, Env)
            end
    end.

-spec render_response(Req :: cowboy_req:req(), Env :: map()) -> {ok, Req :: cowboy_req:req(), State :: map()}.
render_response(Req = #{resp_status_code := StatusCode}, Env) ->
    Req0 = cowboy_req:reply(StatusCode, Req),
    {ok, Req0, Env};
render_response(Req, Env) ->
    Req0 = cowboy_req:reply(200, Req),
    {ok, Req0, Env}.


render_response(Req, Env, StatusCode) ->
    case application:get_env(nova, render_error_pages, true) of
        true ->
            case nova_router:lookup_url(StatusCode) of
                {error, _} ->
                    %% Render the internal view of nova
                    {ok, Req0} = nova_basic_handler:handle_status({status, StatusCode}, {dummy, dummy}, Req),
                    render_response(Req0, Env);
                {ok, _Bindings, #nova_handler_value{module = EMod, function = EFunc}} ->
                    %% Recurse to the execute function to render error page
                    execute(Req, Env#{app => nova, module => EMod, function => EFunc})
            end;
        false ->
            {ok, Req0} = nova_basic_handler:handle_status({status, StatusCode}, {dummy, dummy}, Req),
            render_response(Req0, Env)
    end.


function_exported(Module, Function, Arity) ->
    Exports = erlang:apply(Module, module_info, [exports]),
    case proplists:get_value(Function, Exports) of
        A when A == Arity ->
            true;
        _ ->
            false
    end.