diff --git a/rebar.config b/rebar.config index 62a190b..c20a70a 100644 --- a/rebar.config +++ b/rebar.config @@ -48,6 +48,11 @@ deprecated_functions ]}. +%% Arizona is an optional runtime dependency +{xref_ignores, [{arizona_cowboy_request, new, 1}, + {arizona_view, call_mount_callback, 3}, + {arizona_renderer, render_layout, 1}]}. + {plugins, [rebar3_ex_doc, {rebar3_erlydtl_plugin, ".*", diff --git a/src/nova_arizona.erl b/src/nova_arizona.erl new file mode 100644 index 0000000..4ceb98a --- /dev/null +++ b/src/nova_arizona.erl @@ -0,0 +1,131 @@ +%%%------------------------------------------------------------------- +%%% @doc +%%% Bridge module for Arizona LiveView integration with Nova. +%%% +%%% Provides rendering of Arizona views as Nova controller callbacks +%%% and manages the Arizona dispatch table for WebSocket view resolution. +%%% +%%% Arizona is an optional dependency - this module only has runtime +%%% dependencies on Arizona modules, which are only invoked when +%%% liveview routes are actually defined. +%%% @end +%%%------------------------------------------------------------------- +-module(nova_arizona). + +-export([ + render_view/3, + register_view/3, + finalize/1, + resolve_view/1 +]). + +%% Arizona modules are optional runtime dependencies +-ignore_xref([{arizona_cowboy_request, new, 1}, + {arizona_view, call_mount_callback, 3}, + {arizona_renderer, render_layout, 1}, + {cowboy_router, execute, 2}, + resolve_view/1]). +-dialyzer({nowarn_function, [render_view/3, resolve_view/1, finalize/1]}). + +-include("../include/nova_router.hrl"). +-include_lib("kernel/include/logger.hrl"). + +-define(ARIZONA_VIEWS_KEY, nova_arizona_views). + +%%-------------------------------------------------------------------- +%% @doc +%% Called from nova_router during route compilation for each liveview +%% route. Accumulates view registrations in persistent_term. +%% @end +%%-------------------------------------------------------------------- +-spec register_view(Path :: string(), ViewModule :: module(), MountArg :: term()) -> ok. +register_view(Path, ViewModule, MountArg) -> + Views = persistent_term:get(?ARIZONA_VIEWS_KEY, []), + persistent_term:put(?ARIZONA_VIEWS_KEY, [{Path, ViewModule, MountArg} | Views]), + ok. + +%%-------------------------------------------------------------------- +%% @doc +%% Called from nova_router after all routes have been compiled. +%% If any liveview routes were registered: +%% 1. Builds Arizona's Cowboy dispatch table (persistent_term) so +%% arizona_websocket can resolve views from ?path= query params +%% 2. Sets Arizona's pubsub scope to nova_scope +%% 3. Auto-registers /live WebSocket endpoint for Arizona connections +%% +%% Returns the dispatch tree unchanged if no liveview routes exist. +%% @end +%%-------------------------------------------------------------------- +-spec finalize(Dispatch :: term()) -> term(). +finalize(Dispatch) -> + Views = persistent_term:get(?ARIZONA_VIEWS_KEY, []), + case Views of + [] -> + Dispatch; + _ -> + setup_arizona_dispatch(Views), + setup_live_websocket(Dispatch) + end. + +%%-------------------------------------------------------------------- +%% @doc +%% Nova controller callback for rendering Arizona views. +%% Returns {status, 200, Headers, Html} which Nova's handler system +%% processes via nova_basic_handler:handle_status/3. +%% @end +%%-------------------------------------------------------------------- +-spec render_view(ViewModule :: module(), MountArg :: term(), Req :: cowboy_req:req()) -> + {status, 200, map(), iodata()}. +render_view(ViewModule, MountArg, Req) -> + ArizonaReq = arizona_cowboy_request:new(Req), + View = arizona_view:call_mount_callback(ViewModule, MountArg, ArizonaReq), + {Html, _RenderView} = arizona_renderer:render_layout(View), + {status, 200, #{<<"content-type">> => <<"text/html; charset=utf-8">>}, Html}. + +%%-------------------------------------------------------------------- +%% @doc +%% Resolve a view module from the Arizona dispatch table. +%% Used by the WebSocket handler to determine which view to mount. +%% @end +%%-------------------------------------------------------------------- +-spec resolve_view(Req :: cowboy_req:req()) -> {view, module(), term(), list()}. +resolve_view(Req) -> + {ok, _Req, Env} = cowboy_router:execute( + Req, + #{dispatch => {persistent_term, arizona_dispatch}} + ), + maps:get(handler_opts, Env). + + +%%%%%%%%%%%%%%%%%%%%%%%% +%% INTERNAL FUNCTIONS %% +%%%%%%%%%%%%%%%%%%%%%%%% + +%% Build Arizona's Cowboy dispatch table from accumulated view routes. +%% This allows arizona_websocket to resolve view modules when clients +%% connect to /live?path=/some-page via arizona_server:get_handler_opts/1. +setup_arizona_dispatch(Views) -> + CowboyRoutes = [{list_to_binary(Path), arizona_view_handler, + {view, Mod, MountArg, []}} + || {Path, Mod, MountArg} <- Views], + ArizonaDispatch = cowboy_router:compile([{'_', CowboyRoutes}]), + persistent_term:put(arizona_dispatch, ArizonaDispatch), + ?LOG_DEBUG(#{action => <<"Arizona dispatch table built">>, + view_count => length(Views)}). + +%% Auto-register /live WebSocket endpoint for Arizona connections. +%% Uses Nova's WS handler (nova_ws_handler) so that Arizona LiveView +%% connections go through the Nova plugin pipeline. +setup_live_websocket(Dispatch) -> + LivePath = application:get_env(nova, arizona_live_path, "/live"), + Value = #cowboy_handler_value{ + app = nova, + handler = nova_ws_handler, + arguments = #{module => arizona_nova_websocket, + controller_data => #{view_resolver => fun nova_arizona:resolve_view/1}}, + plugins = [], + secure = false + }, + ?LOG_DEBUG(#{action => <<"Adding Arizona WebSocket route">>, + route => LivePath}), + routing_tree:insert('_', LivePath, '_', Value, Dispatch). diff --git a/src/nova_basic_handler.erl b/src/nova_basic_handler.erl index ad44095..777cceb 100644 --- a/src/nova_basic_handler.erl +++ b/src/nova_basic_handler.erl @@ -270,6 +270,13 @@ handle_websocket({websocket, ControllerData}, Callback, Req) -> %% Example of a valid return value is {reply, Frame, State} %% @end %%----------------------------------------------------------------- +handle_ws({reply, Frames, NewControllerData}, State = #{commands := Commands}) when is_list(Frames) -> + State#{controller_data => NewControllerData, + commands => Frames ++ Commands}; +handle_ws({reply, Frames, NewControllerData, hibernate}, State = #{commands := Commands}) when is_list(Frames) -> + State#{controller_data => NewControllerData, + commands => Frames ++ Commands, + hibernate => true}; handle_ws({reply, Frame, NewControllerData}, State = #{commands := Commands}) -> State#{controller_data => NewControllerData, commands => [Frame|Commands]}; diff --git a/src/nova_pubsub.erl b/src/nova_pubsub.erl index 479b87f..3e43613 100644 --- a/src/nova_pubsub.erl +++ b/src/nova_pubsub.erl @@ -46,8 +46,13 @@ join/2, leave/1, leave/2, + broadcast/2, broadcast/3, local_broadcast/3, + subscribe/1, + subscribe/2, + unsubscribe/1, + unsubscribe/2, get_members/1, get_local_members/1 ]). @@ -152,6 +157,60 @@ get_members(Channel) -> get_local_members(Channel) -> pg:get_local_members(?SCOPE, Channel). +%%-------------------------------------------------------------------- +%% @doc +%% Broadcast to all subscribers of a binary topic. +%% Sends {pubsub_message, Topic, Data} which Arizona views handle +%% in their handle_event/3 callback. +%% @end +%%-------------------------------------------------------------------- +-spec broadcast(Topic :: binary(), Data :: term()) -> ok. +broadcast(Topic, Data) when is_binary(Topic) -> + Members = pg:get_members(?SCOPE, Topic), + [Receiver ! {pubsub_message, Topic, Data} || Receiver <- Members], + ok. + +%%-------------------------------------------------------------------- +%% @doc +%% Subscribe the calling process to a binary topic on the shared scope. +%% @end +%%-------------------------------------------------------------------- +-spec subscribe(Topic :: binary()) -> ok. +subscribe(Topic) when is_binary(Topic) -> + subscribe(Topic, self()). + +%%-------------------------------------------------------------------- +%% @doc +%% Subscribe a process to a binary topic on the shared scope. +%% @end +%%-------------------------------------------------------------------- +-spec subscribe(Topic :: binary(), Pid :: pid()) -> ok. +subscribe(Topic, Pid) when is_binary(Topic), is_pid(Pid) -> + pg:join(?SCOPE, Topic, Pid). + +%%-------------------------------------------------------------------- +%% @doc +%% Unsubscribe the calling process from a binary topic. +%% @end +%%-------------------------------------------------------------------- +-spec unsubscribe(Topic :: binary()) -> ok | not_joined. +unsubscribe(Topic) when is_binary(Topic) -> + unsubscribe(Topic, self()). + +%%-------------------------------------------------------------------- +%% @doc +%% Unsubscribe a process from a binary topic. +%% @end +%%-------------------------------------------------------------------- +-spec unsubscribe(Topic :: binary(), Pid :: pid()) -> ok | not_joined. +unsubscribe(Topic, Pid) when is_binary(Topic), is_pid(Pid) -> + pg:leave(?SCOPE, Topic, Pid). + + +%%%%%%%%%%%%%%%%%%%%%%%% +%% INTERNAL FUNCTIONS %% +%%%%%%%%%%%%%%%%%%%%%%%% + create_envelope(Channel, Sender, Topic, Payload) -> #nova_pubsub{channel = Channel, sender = Sender, diff --git a/src/nova_router.erl b/src/nova_router.erl index 16d3b8a..e5c4ef5 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -63,10 +63,14 @@ plugins() -> -spec compile(Apps :: [atom() | {atom(), map()}]) -> host_tree(). compile(Apps) -> UseStrict = application:get_env(nova, use_strict_routing, false), + %% Reset Arizona view accumulator before compilation + persistent_term:put(nova_arizona_views, []), Dispatch = compile(Apps, routing_tree:new(#{use_strict => UseStrict, convert_to_binary => true}), #{}), + %% Finalize Arizona integration (builds dispatch table, adds /live WS route) + Dispatch1 = nova_arizona:finalize(Dispatch), StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - StorageBackend:put(nova_dispatch, Dispatch), - Dispatch. + StorageBackend:put(nova_dispatch, Dispatch1), + Dispatch1. -spec execute(Req, Env :: cowboy_middleware:env()) -> {ok, Req, Env0} | {stop, Req} when Req::cowboy_req:req(), @@ -327,7 +331,7 @@ parse_url(Host, [{RemotePath, LocalPath, Options}|Tl], T = #{prefix := Prefix}, Value0 = #nova_handler_value{ app = App, - callback = fun nova_file_controller:TargetFun/1, + callback = erlang:make_fun(nova_file_controller, TargetFun, 1), extra_state = #{static => Payload, options => Options}, plugins = Value#nova_handler_value.plugins, secure = Secure @@ -370,6 +374,27 @@ parse_url(Host, [{Path, Callback, Options}|Tl], T = #{prefix := Prefix}, Value = router_file => maps:get(router_file, Options, undefined)}), parse_url(Host, Tl, T, Value, Tree) end; +parse_url(Host, + [{Path, Mod, #{protocol := liveview} = Options} | Tl], + T = #{prefix := Prefix}, Value = #nova_handler_value{app = App}, Tree) + when is_atom(Mod) -> + MountArg = maps:get(mount_arg, Options, #{}), + RealPath = concat_strings(Prefix, Path), + Callback = fun(Req) -> nova_arizona:render_view(Mod, MountArg, Req) end, + Methods = maps:get(methods, Options, ['_']), + ExtraState = maps:get(extra_state, Options, undefined), + Value0 = Value#nova_handler_value{extra_state = ExtraState}, + CompiledPaths = + lists:foldl( + fun(Method, Tree0) -> + BinMethod = method_to_binary(Method), + Value1 = Value0#nova_handler_value{callback = Callback}, + ?LOG_DEBUG(#{action => <<"Adding liveview route">>, + route => RealPath, app => App, method => Method}), + insert(Host, RealPath, BinMethod, Value1, Tree0) + end, Tree, Methods), + nova_arizona:register_view(RealPath, Mod, MountArg), + parse_url(Host, Tl, T, Value, CompiledPaths); parse_url(Host, [{Path, Mod, #{protocol := ws}} | Tl], T = #{prefix := Prefix}, #nova_handler_value{app = App, secure = Secure} = Value, diff --git a/test/nova_file_controller_tests.erl b/test/nova_file_controller_tests.erl new file mode 100644 index 0000000..c647b0a --- /dev/null +++ b/test/nova_file_controller_tests.erl @@ -0,0 +1,112 @@ +-module(nova_file_controller_tests). +-include_lib("eunit/include/eunit.hrl"). + +%%==================================================================== +%% get_file/1 +%%==================================================================== + +get_file_serves_existing_file_test() -> + Filepath = create_temp_file("hello world"), + Req = #{extra_state => #{static => {file, Filepath}, options => #{}}, + headers => #{}}, + Result = nova_file_controller:get_file(Req), + ?assertMatch({sendfile, 200, #{}, {0, 11, Filepath}, _MimeType}, Result), + file:delete(Filepath). + +get_file_priv_file_test() -> + %% Use a known priv file from nova itself + PrivDir = code:priv_dir(nova), + case file:list_dir(PrivDir) of + {ok, [F|_]} -> + FullPath = filename:join(PrivDir, F), + case filelib:is_file(FullPath) of + true -> + Req = #{extra_state => #{static => {priv_file, nova, F}, + options => #{}}, + headers => #{}}, + Result = nova_file_controller:get_file(Req), + ?assertMatch({sendfile, 200, _, _, _}, Result); + false -> + ok + end; + _ -> + ok + end. + +get_file_returns_404_for_bad_req_test() -> + ?assertEqual({status, 404}, nova_file_controller:get_file(#{})). + +get_file_range_request_test() -> + Filepath = create_temp_file("0123456789"), + Req = #{extra_state => #{static => {file, Filepath}, options => #{}}, + headers => #{<<"range">> => <<"bytes=2-5">>}}, + Result = nova_file_controller:get_file(Req), + ?assertMatch({sendfile, 206, _, {2, 4, Filepath}, _}, Result), + file:delete(Filepath). + +get_file_invalid_range_test() -> + Filepath = create_temp_file("short"), + Req = #{extra_state => #{static => {file, Filepath}, options => #{}}, + headers => #{<<"range">> => <<"bytes=100-200">>}}, + Result = nova_file_controller:get_file(Req), + ?assertMatch({status, 416, _}, Result), + file:delete(Filepath). + +%%==================================================================== +%% get_dir/1 +%%==================================================================== + +get_dir_with_pathinfo_serves_file_test() -> + Dir = create_temp_dir(), + Filepath = filename:join(Dir, "test.txt"), + ok = file:write_file(Filepath, <<"dir file">>), + Req = #{extra_state => #{pathinfo => [<<"test.txt">>], + static => {dir, Dir}, + options => #{}}, + headers => #{}}, + Result = nova_file_controller:get_dir(Req), + ?assertMatch({sendfile, 200, _, _, _}, Result), + file:delete(Filepath), + file:del_dir(Dir). + +get_dir_pathinfo_not_found_test() -> + Dir = create_temp_dir(), + Req = #{extra_state => #{pathinfo => [<<"nope.txt">>], + static => {dir, Dir}, + options => #{}}, + headers => #{}}, + ?assertEqual({status, 404}, nova_file_controller:get_dir(Req)), + file:del_dir(Dir). + +get_dir_returns_404_for_bad_req_test() -> + ?assertEqual({status, 404}, nova_file_controller:get_dir(#{})). + +%%==================================================================== +%% Router callback construction (regression for make_fun fix) +%%==================================================================== + +router_static_callback_is_callable_test() -> + %% Verify that erlang:make_fun/3 produces a callable function + Callback = erlang:make_fun(nova_file_controller, get_file, 1), + ?assert(is_function(Callback, 1)), + %% Should return 404 for a bad request, not crash + ?assertEqual({status, 404}, Callback(#{})). + +router_static_dir_callback_is_callable_test() -> + Callback = erlang:make_fun(nova_file_controller, get_dir, 1), + ?assert(is_function(Callback, 1)), + ?assertEqual({status, 404}, Callback(#{})). + +%%==================================================================== +%% Helpers +%%==================================================================== + +create_temp_file(Content) -> + Filename = filename:join("/tmp", "nova_test_" ++ integer_to_list(erlang:unique_integer([positive]))), + ok = file:write_file(Filename, Content), + Filename. + +create_temp_dir() -> + Dir = filename:join("/tmp", "nova_test_dir_" ++ integer_to_list(erlang:unique_integer([positive]))), + ok = file:make_dir(Dir), + Dir.