src/nova_basic_handler.erl

-module(nova_basic_handler).
-export([
         handle_json/3,
         handle_ok/3,
         handle_status/3,
         handle_redirect/3,
         handle_sendfile/3,
         handle_websocket/3
        ]).

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

-type mod_fun() :: {Module :: atom(), Function :: atom()} | undefined.
-type erlydtl_vars() :: map() | [{Key :: atom() | binary() | string(), Value :: any()}].



%%--------------------------------------------------------------------
%% @doc
%% Handler for JSON. It takes two different return objects:
%%
%%   {json, JSON :: map()} returns the JSON encoded to the user.
%%     If the operation was a POST the HTTP-status code will be 201, otherwise
%%     200.
%%
%%   {json, StatusCode :: integer(), Headers :: map(), JSON :: map()} Same
%%     operation as the above except you can set custom status code and custom
%%     headers.
%% @end
%%--------------------------------------------------------------------
-spec handle_json({json, JSON :: map()} | {json, StatusCode :: integer(), Headers :: map(), JSON :: map()},
                  ModFun :: mod_fun(), Req :: cowboy_req:req()) -> {ok, State :: cowboy_req:req()} |
                                                                   {Module :: atom(), Req :: cowboy_req:req()}.
handle_json({json, StatusCode, Headers, JSON}, _ModFun, Req) ->
    try json:encode(JSON, [binary, maps]) of
        EncodedJSON ->
            Headers0 = maps:merge(#{<<"content-type">> => <<"application/json">>}, Headers),
            Req0 = cowboy_req:set_resp_headers(Headers0, Req),
            Req1 = cowboy_req:set_resp_body(EncodedJSON, Req0),
            Req2 = Req1#{resp_status_code => StatusCode},
            {ok, Req2}
    catch
        Class:Reason:Stacktrace ->
            logger:error(#{msg => "Error while processing JSON", class => Class,
                           reason => Reason, stacktrace => Stacktrace}),
            handle_status({status, 500}, undefined, Req)
    end;
handle_json({json, JSON}, ModFun, Req = #{method := Method}) ->
    case Method of
        <<"POST">> ->
            handle_json({json, 201, #{}, JSON}, ModFun, Req);
        _ ->
            handle_json({json, 200, #{}, JSON}, ModFun, Req)
    end.



%%--------------------------------------------------------------------
%% @doc
%% Handler for regular views. This will render a template with given variables.
%% If not another view is specified in options a view that corresponds to the controller will be
%% rendered.
%%
%%
%% -module(my_first_controller).
%% -compile(export_all).
%%
%% my_function(_Req) ->
%%    {ok, []}.
%%
%% The example above will then render the view named 'app_main.dtl'
%%
%% Options can be specified as follows:
%%
%% - view - Specifies if another view should be rendered instead of default one
%%
%% - headers - Custom headers
%% @end
%%--------------------------------------------------------------------
-spec handle_ok({ok, Variables :: erlydtl_vars()} | {ok, Variables :: erlydtl_vars(), Options :: map()},
                ModFun :: mod_fun(), Req :: cowboy_req:req()) -> {ok, cowboy_req:req()}.
handle_ok({ok, Variables}, {Mod, _Func}, Req) ->
    %% Derive the view from module
    ViewNameAtom = get_view_name(Mod),
    handle_view(ViewNameAtom, Variables, #{}, Req);
handle_ok({ok, Variables, Options}, {Mod, _Func}, Req) ->
    View =
        case maps:get(view, Options, undefined) of
            undefined ->
                ViewName = atom_to_list(Mod) ++ "_dtl",
                list_to_atom(ViewName);
            CustomView when is_atom(CustomView) ->
                ViewName = atom_to_list(CustomView) ++ "_dtl",
                list_to_atom(ViewName);
            CustomView ->
                list_to_atom(CustomView ++ "_dtl")
        end,
    handle_view(View, Variables, Options, Req).


%%--------------------------------------------------------------------
%% @doc
%% Handler for returning http status codes. There's three different ways one can
%% return status code. The most basic case is {status, Status} where Status is
%% the code that should be returned.
%%
%% If there's a need for additional headers to be sent along with the http code one can specify
%% a third argument that is a map with header-fields.
%%
%% One can also send in a body as a fourth argument in the tuple. It can either be a binary or
%% a map. If it's a map it will be concidered a JSON-structure and encoded.
%%
%% @end
%%--------------------------------------------------------------------
-spec handle_status({status, StatusCode :: integer()} |
                    {status, StatusCode :: integer(), ExtraHeaders :: map()} |
                    {status, StatusCode :: integer(), ExtraHeaders :: map(), Body :: binary() | map()},
                    ModFun :: mod_fun(), Req :: cowboy_req:req()) -> {ok, Req :: cowboy_req:req()}.
handle_status({status, Status, ExtraHeaders, JSON}, _ModFun, Req) when is_map(JSON) ->
    %% We do not need to render a status page since we just return a JSON structure
    Headers0 = maps:merge(#{<<"content-type">> => <<"application/json">>}, ExtraHeaders),
    Req0 = cowboy_req:set_resp_headers(Headers0, Req),
    Req1 = Req0#{resp_status_code => Status},
    Req2 = cowboy_req:set_resp_body(json:encode(JSON, [binary]), Req1),
    {ok, Req2};
handle_status({status, Status, ExtraHeaders, Body}, _ModFun, Req) ->
    %% Body is a binary - just send it out
    Req0 = cowboy_req:set_resp_headers(ExtraHeaders, Req),
    Req1 = Req0#{resp_status_code => Status},
    Req2 = cowboy_req:set_resp_body(Body, Req1),
    {ok, Req2};
handle_status({status, Status, ExtraHeaders}, _ModFun, Req) ->
    Req0 = cowboy_req:set_resp_headers(ExtraHeaders, Req),
    Req1 = Req0#{resp_status_code => Status},
    {ok, Req2, _Env} =  nova_router:render_status_page(Status, #{}, Req1),
    {ok, Req2};
handle_status({status, Status}, ModFun, State) when is_integer(Status) ->
    handle_status({status, Status, #{}}, ModFun, State).


%%--------------------------------------------------------------------
%% @doc
%% Handles redirects. This will return a 302-status code with a location given
%% by the user. Something like {redirect, "/login"} will send a
%% 302 with location set to "/login"
%% @end
%%-----------------------------------------------------------------
-spec handle_redirect({redirect, Route :: list()}, ModFun :: mod_fun(),
                      Req :: cowboy_req:req()) -> {ok, Req :: cowboy_req:req()}.
handle_redirect({redirect, Route}, _ModFun, Req) ->
    Headers = #{<<"Location">> => list_to_binary(Route)},
    Req0 = cowboy_req:set_resp_headers(Headers, Req),
    Req1 = Req0#{resp_status_code => 302},
    {ok, Req1}.

%%--------------------------------------------------------------------
%% @doc
%% Handles sendfile.
%% @end
%%-----------------------------------------------------------------
-spec handle_sendfile({sendfile, StatusCode :: integer(), Headers :: map(), {Offset :: integer(),
                                                                             Length :: integer(),
                                                                             Path :: list()}, Mime :: binary()},
                  ModFun :: mod_fun(), Req) -> {ok, Req} when Req :: cowboy_req:req().
handle_sendfile({sendfile, StatusCode, Headers, {Offset, Length, Path}, Mime}, _ModFun, Req) ->
    Headers0 = maps:merge(#{<<"content-type">> => Mime}, Headers),
    Req0 = cowboy_req:set_resp_headers(Headers0, Req),
    Req1 = cowboy_req:set_resp_body({sendfile, Offset, Length, Path}, Req0),
    Req2 = Req1#{resp_status_code => StatusCode},
    {ok, Req2}.


%%--------------------------------------------------------------------
%% @doc
%% Handles upgrading to websocket
%% @end
%%-----------------------------------------------------------------
-spec handle_websocket({websocket, ControllerData :: any()}, ModFun :: mod_fun(), Req :: cowboy_req:req()) ->
                              {ok, Req :: cowboy_req:req()}.
handle_websocket({websocket, ControllerData}, {Module, _Fun}, Req) ->
    case Module:init(ControllerData) of
        {ok, NewControllerData} ->
            {cowboy_websocket, Req#{controller_data => NewControllerData}, #{}};
        Error ->
            logger:error(#{msg => "Handler returned unsupported result", handler => Module, return_obj => Error}),
            %% Render 500
            {ok, Req}
    end.


%%%===================================================================
%%% Internal functions
%%%===================================================================

handle_view(View, Variables, Options, Req) ->
    {ok, HTML} = render_dtl(View, Variables, []),
    Headers =
        case maps:get(headers, Options, undefined) of
            undefined ->
                #{<<"content-type">> => <<"text/html">>};
            UserHeaders ->
                UserHeaders
        end,
    StatusCode = maps:get(status_code, Options, 200),
    Req0 = cowboy_req:set_resp_headers(Headers, Req),
    Req1 = cowboy_req:set_resp_body(HTML, Req0),
    Req2 = Req1#{resp_status_code => StatusCode},
    {ok, Req2}.

render_dtl(View, Variables, Options) ->
    case code:is_loaded(View) of
        false ->
            case code:load_file(View) of
                {error, Reason} ->
                    %% Cast a warning since the module could not be found
                    logger:error(#{msg => "Nova could not render template", template => View, reason => Reason}),
                    throw({error, template_not_found});
                _ ->
                    View:render(Variables, Options)
            end;
        _ ->
            View:render(Variables, Options)
    end.


get_view_name(Mod) when is_atom(Mod) ->
    StrName = get_view_name(erlang:atom_to_list(Mod)),
    erlang:list_to_atom(StrName);
get_view_name([$_, $c, $o, $n, $t, $r, $o, $l, $l, $e, $r]) ->
    "_dtl";
get_view_name([H|T]) ->
    [H|get_view_name(T)].