Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -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, ".*",
Expand Down
131 changes: 131 additions & 0 deletions src/nova_arizona.erl
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 7 additions & 0 deletions src/nova_basic_handler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]};
Expand Down
59 changes: 59 additions & 0 deletions src/nova_pubsub.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
]).
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions src/nova_router.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
112 changes: 112 additions & 0 deletions test/nova_file_controller_tests.erl
Original file line number Diff line number Diff line change
@@ -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.
Loading