%%% @author Niclas Axelsson <niclas@burbas.se>
%%% @doc
%%% Callback controller for handling websockets
%%% @end
-module(nova_ws_handler).
-export([
init/2,
terminate/3,
websocket_init/1,
websocket_handle/2,
websocket_info/2
]).
-include_lib("kernel/include/logger.hrl").
-type nova_ws_state() :: #{controller_data := map(),
mod := atom(),
_ := _}.
-export_type([nova_ws_state/0]).
%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% Public functions %%
%%%%%%%%%%%%%%%%%%%%%%%%%%%
init(Req = #{method := _Method, plugins := Plugins}, State = #{module := Module}) ->
%% Call the http-handler in order to correctly handle potential plugins for the http-request
ControllerData = maps:get(controller_data, State, #{}),
State0 = State#{mod => Module,
plugins => Plugins},
ControllerData2 = ControllerData#{req => Req},
upgrade_ws(Module, Req, State0, ControllerData2).
upgrade_ws(Module, Req, State, ControllerData) ->
case Module:init(ControllerData) of
{ok, NewControllerData} ->
{cowboy_websocket, Req, State#{controller_data => NewControllerData}};
Error ->
?LOG_ERROR(#{msg => <<"Websocket handler returned unknown result">>, handler => Module, returned => Error}),
nova_router:render_status_page(500, Req)
end.
websocket_init(State = #{mod := Mod}) ->
%% Inject the websocket process into the state
ControllerData = maps:get(controller_data, State, #{}),
NewState = State#{controller_data => ControllerData#{ws_handler_process => self()}},
case erlang:function_exported(Mod, websocket_init, 1) of
true ->
handle_ws(Mod, websocket_init, [], NewState);
_ ->
{ok, NewState}
end.
websocket_handle(Frame, State = #{mod := Mod}) ->
handle_ws(Mod, websocket_handle, [Frame], State).
websocket_info(Msg, State = #{mod := Mod}) ->
handle_ws(Mod, websocket_info, [Msg], State).
terminate(Reason, PartialReq, State = #{controller_data := ControllerData, mod := Mod, plugins := Plugins}) ->
case erlang:function_exported(Mod, terminate, 3) of
true ->
erlang:apply(Mod, terminate, [Reason, PartialReq, ControllerData]),
%% Call post_ws_connection-plugins
TerminatePlugins = proplists:get_value(post_ws_connection, Plugins, []),
ControllerState = #{module => Mod,
function => terminate,
arguments => [Reason, PartialReq, State]},
run_plugins(TerminatePlugins, post_ws_connection, ControllerState, State);
_ ->
ok
end;
terminate(Reason, PartialReq, State) ->
?LOG_ERROR(#{msg => <<"Terminate called">>, reason => Reason, partial_req => PartialReq, state => State}),
ok.
handle_ws(Mod, Func, Args, State = #{controller_data := _ControllerData, plugins := Plugins}) ->
PrePlugins = proplists:get_value(pre_ws_request, Plugins, []),
ControllerState = #{module => Mod,
function => Func,
arguments => Args},
%% First run the pre-plugins and if everything goes alright we continue
State0 = State#{commands => []},
case run_plugins(PrePlugins, pre_ws_request, ControllerState, State0) of
{ok, State1} ->
case invoke_controller(Mod, Func, Args, State1) of
{stop, _StopReason} = S ->
S;
State2 ->
%% Run the post-plugins
PostPlugins = proplists:get_value(post_ws_request, Plugins, []),
{ok, State3 = #{commands := Cmds}} = run_plugins(PostPlugins, post_ws_request,
ControllerState, State2),
%% Remove the commands from the map
State4 = maps:remove(commands, State3),
%% Check if we are hibernating
case maps:get(hibernate, State4, false) of
false ->
{Cmds, State4};
_ ->
{Cmds, maps:remove(hibernate, State4), hibernate}
end
end;
{stop, _} = Stop ->
?LOG_WARNING(#{msg => <<"Got stop signal">>, signal => Stop}),
Stop
end;
handle_ws(Mod, Func, Args, State) ->
handle_ws(Mod, Func, Args, State#{controller_data => #{}}).
%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% Private functions %%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%
invoke_controller(Mod, Func, Args, State = #{controller_data := ControllerData}) ->
try erlang:apply(Mod, Func, Args ++ [ControllerData]) of
RetObj ->
case nova_handlers:get_handler(ws) of
{ok, Callback} ->
Callback(RetObj, State);
{error, not_found} ->
?LOG_ERROR(#{msg => <<"Websocket handler not found. Check that a handler is
registered on handle 'ws'">>,
controller => Mod, function => Func, return => RetObj}),
{stop, State}
end
catch
Class:Reason:Stacktrace ->
?LOG_ERROR(#{msg => <<"Controller crashed">>, class => Class,
reason => Reason, stacktrace => Stacktrace}),
{stop, State}
end.
run_plugins([], _Callback, #{controller_result := {stop, _} = Signal}, _State) ->
Signal;
run_plugins([], _Callback, _ControllerState, State) ->
{ok, State};
run_plugins([{Module, Options}|Tl], Callback, ControllerState, State) ->
try erlang:apply(Module, Callback, [ControllerState, State, Options]) of
{ok, ControllerState0, State0} ->
run_plugins(Tl, Callback, ControllerState0, State0);
%% Stop indicates that we want the entire pipe of plugins/controller to be stopped.
{stop, State0} ->
{stop, State0};
%% Break is used to signal that we are stopping further executing of plugins within the same Callback
{break, State0} ->
{ok, State0};
{error, Reason} ->
?LOG_ERROR(#{msg => <<"Plugin returned error">>, plugin => Module, function => Callback, reason => Reason}),
{stop, State}
catch
Class:Reason:Stacktrace ->
?LOG_ERROR(#{msg => <<"Plugin crashed">>, class => Class, reason => Reason, stacktrace => Stacktrace}),
{stop, State}
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.