From f3b0577c9e08fcac1dcfd7a174ef55b346c83d7e Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Mon, 6 Jan 2025 21:17:41 +0100 Subject: [PATCH 01/21] Works for most cases but there is still work left to check if a cowboy server already runs on the configuration given --- src/nova_router.erl | 7 ++++++- src/nova_sup.erl | 49 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/nova_router.erl b/src/nova_router.erl index 414e8b7..0d910cd 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -63,8 +63,13 @@ plugins() -> -spec compile(Apps :: [atom() | {atom(), map()}]) -> host_tree(). compile(Apps) -> UseStrict = application:get_env(nova, use_strict_routing, false), - Dispatch = compile(Apps, routing_tree:new(#{use_strict => UseStrict, convert_to_binary => true}), #{}), StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + + StoredDispatch = StorageBackend:get(nova_dispatch, + routing_tree:new(#{use_strict => UseStrict, + convert_to_binary => true})), + Dispatch = compile(Apps, StoredDispatch, #{}), + %% Write the updated dispatch to storage StorageBackend:put(nova_dispatch, Dispatch), Dispatch. diff --git a/src/nova_sup.erl b/src/nova_sup.erl index 980f625..a21ce01 100644 --- a/src/nova_sup.erl +++ b/src/nova_sup.erl @@ -8,7 +8,10 @@ -behaviour(supervisor). %% API --export([start_link/0]). +-export([ + start_link/0, + add_application/2 + ]). %% Supervisor callbacks -export([init/1]). @@ -36,6 +39,20 @@ start_link() -> supervisor:start_link({local, ?SERVER}, ?MODULE, []). +%%-------------------------------------------------------------------- +%% @doc +%% Add a Nova application. This can either be on the same cowboy server that +%% a previous application was started with, or a new one if the configuration +%% ie port is different. +%% +%% @end +%%-------------------------------------------------------------------- +-spec add_application(App :: atom(), Configuration :: map()) -> {ok, App :: atom(), + Host :: inet:ip_address(), Port :: number()} + | {error, Reason :: any()}. +add_application(App, Configuration) -> + setup_cowboy(App, Configuration). + %%%=================================================================== %%% Supervisor callbacks %%%=================================================================== @@ -56,14 +73,12 @@ init([]) -> intensity => 1, period => 5}, + %% Bootstrap the environment Environment = nova:get_environment(), - nova_pubsub:start(), ?LOG_NOTICE(#{msg => <<"Starting nova">>, environment => Environment}), - Configuration = application:get_env(nova, cowboy_configuration, #{}), - SessionManager = application:get_env(nova, session_manager, nova_session_ets), Children0 = [ @@ -77,7 +92,7 @@ init([]) -> false -> Children0 end, - setup_cowboy(Configuration), + setup_cowboy(), {ok, {SupFlags, Children}}. @@ -99,8 +114,17 @@ child(Id, Type, Mod) -> child(Id, Mod) -> child(Id, worker, Mod). -setup_cowboy(Configuration) -> - case start_cowboy(Configuration) of + +%%%------------------------------------------------------------------- +%%% Nova Cowboy setup +%%%------------------------------------------------------------------- +setup_cowboy() -> + CowboyConfiguration = application:get_env(nova, cowboy_configuration, #{}), + BootstrapApp = application:get_env(nova, bootstrap_application, undefined), + setup_cowboy(BootstrapApp, CowboyConfiguration). + +setup_cowboy(BootstrapApp, Configuration) -> + case start_cowboy(BootstrapApp, Configuration) of {ok, App, Host, Port} -> Host0 = inet:ntoa(Host), CowboyVersion = get_version(cowboy), @@ -114,10 +138,13 @@ setup_cowboy(Configuration) -> ?LOG_ERROR(#{msg => <<"Cowboy could not start">>, reason => Error}) end. + -spec start_cowboy(Configuration :: map()) -> {ok, BootstrapApp :: atom(), Host :: string() | {integer(), integer(), integer(), integer()}, Port :: integer()} | {error, Reason :: any()}. -start_cowboy(Configuration) -> +start_cowboy(BootstrapApp, Configuration) -> + + %% Cowboy configuration Middlewares = [ nova_router, %% Lookup routes nova_plugin_handler, %% Handle pre-request plugins @@ -128,6 +155,10 @@ start_cowboy(Configuration) -> StreamH = [nova_stream_h, cowboy_compress_h, cowboy_stream_h], + + %% Good debug message in case someone wants to double check which config they are running with + logger:debug(#{msg => <<"Configure cowboy">>, stream_handlers => StreamH, middlewares => Middlewares}), + StreamHandlers = maps:get(stream_handlers, Configuration, StreamH), MiddlewareHandlers = maps:get(middleware_handlers, Configuration, Middlewares), Options = maps:get(options, Configuration, #{compress => true}), @@ -136,8 +167,6 @@ start_cowboy(Configuration) -> CowboyOptions1 = Options#{middlewares => MiddlewareHandlers, stream_handlers => StreamHandlers}, - BootstrapApp = application:get_env(nova, bootstrap_application, undefined), - %% Compile the routes Dispatch = case BootstrapApp of From 63083f6ee49c451383b985dea2a6fd732b9ce277 Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Wed, 5 Feb 2025 10:28:04 +0100 Subject: [PATCH 02/21] Add some more support for having multiple cowboy listeners running --- src/nova_request.erl | 157 +++++++++++++++++++++++++++++++++++++++++ src/nova_router.erl | 25 ++++++- src/nova_sup.erl | 162 +++++++++++++++++++++---------------------- 3 files changed, 258 insertions(+), 86 deletions(-) create mode 100644 src/nova_request.erl diff --git a/src/nova_request.erl b/src/nova_request.erl new file mode 100644 index 0000000..0629c03 --- /dev/null +++ b/src/nova_request.erl @@ -0,0 +1,157 @@ +%%%------------------------------------------------------------------- +%%% @author Niclas Axelsson +%%% @copyright (C) 2024, Niclas Axelsson +%%% @doc +%%% +%%% @end +%%% Created : 22 Dec 2024 by Niclas Axelsson +%%%------------------------------------------------------------------- +-module(nova_request). + +-behaviour(gen_server). + +%% API +-export([ + start_link/0 + ]). + +%% gen_server callbacks +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3, + format_status/2 + ]). + +-define(SERVER, ?MODULE). + +-record(state, {}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +%%-------------------------------------------------------------------- +%% @doc +%% Starts the server +%% @end +%%-------------------------------------------------------------------- +-spec start_link() -> {ok, Pid :: pid()} | + {error, Error :: {already_started, pid()}} | + {error, Error :: term()} | + ignore. +start_link() -> + gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Initializes the server +%% @end +%%-------------------------------------------------------------------- +-spec init(Args :: term()) -> {ok, State :: term()} | + {ok, State :: term(), Timeout :: timeout()} | + {ok, State :: term(), hibernate} | + {stop, Reason :: term()} | + ignore. +init([]) -> + process_flag(trap_exit, true), + {ok, #state{}}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Handling call messages +%% @end +%%-------------------------------------------------------------------- +-spec handle_call(Request :: term(), From :: {pid(), term()}, State :: term()) -> + {reply, Reply :: term(), NewState :: term()} | + {reply, Reply :: term(), NewState :: term(), Timeout :: timeout()} | + {reply, Reply :: term(), NewState :: term(), hibernate} | + {noreply, NewState :: term()} | + {noreply, NewState :: term(), Timeout :: timeout()} | + {noreply, NewState :: term(), hibernate} | + {stop, Reason :: term(), Reply :: term(), NewState :: term()} | + {stop, Reason :: term(), NewState :: term()}. +handle_call(_Request, _From, State) -> + Reply = ok, + {reply, Reply, State}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Handling cast messages +%% @end +%%-------------------------------------------------------------------- +-spec handle_cast(Request :: term(), State :: term()) -> + {noreply, NewState :: term()} | + {noreply, NewState :: term(), Timeout :: timeout()} | + {noreply, NewState :: term(), hibernate} | + {stop, Reason :: term(), NewState :: term()}. +handle_cast(_Request, State) -> + {noreply, State}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Handling all non call/cast messages +%% @end +%%-------------------------------------------------------------------- +-spec handle_info(Info :: timeout() | term(), State :: term()) -> + {noreply, NewState :: term()} | + {noreply, NewState :: term(), Timeout :: timeout()} | + {noreply, NewState :: term(), hibernate} | + {stop, Reason :: normal | term(), NewState :: term()}. +handle_info(_Info, State) -> + {noreply, State}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% This function is called by a gen_server when it is about to +%% terminate. It should be the opposite of Module:init/1 and do any +%% necessary cleaning up. When it returns, the gen_server terminates +%% with Reason. The return value is ignored. +%% @end +%%-------------------------------------------------------------------- +-spec terminate(Reason :: normal | shutdown | {shutdown, term()} | term(), + State :: term()) -> any(). +terminate(_Reason, _State) -> + ok. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Convert process state when code is changed +%% @end +%%-------------------------------------------------------------------- +-spec code_change(OldVsn :: term() | {down, term()}, + State :: term(), + Extra :: term()) -> {ok, NewState :: term()} | + {error, Reason :: term()}. +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% This function is called for changing the form and appearance +%% of gen_server status when it is returned from sys:get_status/1,2 +%% or when it appears in termination error logs. +%% @end +%%-------------------------------------------------------------------- +-spec format_status(Opt :: normal | terminate, + Status :: list()) -> Status :: term(). +format_status(_Opt, Status) -> + Status. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== diff --git a/src/nova_router.erl b/src/nova_router.erl index 0d910cd..ddac44b 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -25,12 +25,13 @@ %% Expose the router-callback routes/1, - %% Modulates the routes-table - add_routes/2, - %% Fetch information about the routing table plugins/0, compiled_apps/0 + + %% Modulates the routes-table + add_routes/1, + add_routes/2 ]). -include_lib("routing_tree/include/routing_tree.hrl"). @@ -145,6 +146,24 @@ lookup_url(Host, Path, Method) -> lookup_url(Host, Path, Method, Dispatch) -> routing_tree:lookup(Host, Path, Method, Dispatch). + +%%-------------------------------------------------------------------- +%% @doc +%% Works the same way as add_routes/2 but with the exception that you +%% don't need to provide the routes explicitly. When using this it's +%% expected that there's a routing-module associated with the application. +%% Eg. for the application 'test' the corresponding router would then be +%% 'test_router'. Read more about routers in the official documentation. +%% @end +%%-------------------------------------------------------------------- +-spec add_routes(App :: atom()) -> ok. +add_routes(App) -> + Router = erlang:list_to_atom(io_lib:format("~s_router", [App])), + Env = nova:get_environment(), + %% Call the router + Routes = Router:routes(Env), + add_routes(App, Routes). + %%-------------------------------------------------------------------- %% @doc %% Add routes to the dispatch-table for the given app. The routes diff --git a/src/nova_sup.erl b/src/nova_sup.erl index a21ce01..7e98b5d 100644 --- a/src/nova_sup.erl +++ b/src/nova_sup.erl @@ -20,10 +20,11 @@ -include("../include/nova.hrl"). -define(SERVER, ?MODULE). + -define(NOVA_LISTENER, nova_listener). -define(NOVA_STD_PORT, 8080). -define(NOVA_STD_SSL_PORT, 8443). - +-define(COWBOY_LISTENERS, cowboy_listeners). %%%=================================================================== %%% API functions @@ -139,101 +140,96 @@ setup_cowboy(BootstrapApp, Configuration) -> end. --spec start_cowboy(Configuration :: map()) -> +-spec start_cowboy(BootstrapApp :: atom(), Configuration :: map()) -> {ok, BootstrapApp :: atom(), Host :: string() | {integer(), integer(), integer(), integer()}, Port :: integer()} | {error, Reason :: any()}. start_cowboy(BootstrapApp, Configuration) -> - - %% Cowboy configuration - Middlewares = [ - nova_router, %% Lookup routes - nova_plugin_handler, %% Handle pre-request plugins - nova_security_handler, %% Handle security - nova_handler, %% Controller - nova_plugin_handler %% Handle post-request plugins - ], - StreamH = [nova_stream_h, - cowboy_compress_h, - cowboy_stream_h], - - %% Good debug message in case someone wants to double check which config they are running with - logger:debug(#{msg => <<"Configure cowboy">>, stream_handlers => StreamH, middlewares => Middlewares}), - - StreamHandlers = maps:get(stream_handlers, Configuration, StreamH), - MiddlewareHandlers = maps:get(middleware_handlers, Configuration, Middlewares), - Options = maps:get(options, Configuration, #{compress => true}), - - %% Build the options map - CowboyOptions1 = Options#{middlewares => MiddlewareHandlers, - stream_handlers => StreamHandlers}, - - %% Compile the routes - Dispatch = - case BootstrapApp of - undefined -> - ?LOG_ERROR(#{msg => <<"You need to define bootstrap_application option in configuration">>}), - throw({error, no_nova_app_defined}); - App -> - ExtraApps = application:get_env(App, nova_apps, []), - nova_router:compile([nova|[App|ExtraApps]]) - end, - - CowboyOptions2 = - case application:get_env(nova, use_persistent_term, true) of - true -> - CowboyOptions1; - _ -> - CowboyOptions1#{env => #{dispatch => Dispatch}} - end, - + %% Determine if we have an already started cowboy on the host/port configuration Host = maps:get(ip, Configuration, { 0, 0, 0, 0}), - - case maps:get(use_ssl, Configuration, false) of - false -> - Port = maps:get(port, Configuration, ?NOVA_STD_PORT), - case cowboy:start_clear( - ?NOVA_LISTENER, - [{port, Port}, - {ip, Host}], - CowboyOptions2) of - {ok, _Pid} -> - {ok, BootstrapApp, Host, Port}; - Error -> - Error - end; + Port = maps:get(port, Configuration, ?NOVA_STD_PORT), + + Listeners = nova:get_env(?COWBOY_LISTENERS, []), + AlreadyStarted = lists:any(fun({X, Y}) -> X == Host andalso Y == Port end, Listeners), + + %% If yes we only need to add things to the dispatch + case AlreadyStarted of + true -> + %% A cowboy listener is already running on this host/port configuration - just add to the + %% dispatch. + logger:info(#{msg => <<"There's already a Cowboy listener running with the host/port config. Adding routes to dispatch.">>, host => Host, port => Port}), + ok; _ -> - case maps:get(ca_cert, Configuration, undefined) of - undefined -> - Port = maps:get(ssl_port, Configuration, ?NOVA_STD_SSL_PORT), - SSLOptions = maps:get(ssl_options, Configuration, #{}), - TransportOpts = maps:put(port, Port, SSLOptions), - TransportOpts1 = maps:put(ip, Host, TransportOpts), - - case cowboy:start_tls( - ?NOVA_LISTENER, maps:to_list(TransportOpts1), CowboyOptions2) of + %% Cowboy configuration + Middlewares = [ + nova_router, %% Lookup routes + nova_plugin_handler, %% Handle pre-request plugins + nova_security_handler, %% Handle security + nova_handler, %% Controller + nova_plugin_handler %% Handle post-request plugins + ], + StreamH = [ + nova_stream_h, + cowboy_compress_h, + cowboy_stream_h + ], + + %% Good debug message in case someone wants to double check which config they are running with + logger:debug(#{msg => <<"Configure cowboy">>, stream_handlers => StreamH, middlewares => Middlewares}), + + StreamHandlers = maps:get(stream_handlers, Configuration, StreamH), + MiddlewareHandlers = maps:get(middleware_handlers, Configuration, Middlewares), + Options = maps:get(options, Configuration, #{compress => true}), + + %% Build the options map + CowboyOptions1 = Options#{middlewares => MiddlewareHandlers, + stream_handlers => StreamHandlers}, + + %% Compile the routes + Dispatch = + case BootstrapApp of + undefined -> + ?LOG_ERROR(#{msg => <<"You need to define bootstrap_application option in configuration">>}), + throw({error, no_nova_app_defined}); + App -> + ExtraApps = application:get_env(App, nova_apps, []), + nova_router:compile([nova|[App|ExtraApps]]) + end, + + CowboyOptions2 = + case application:get_env(nova, use_persistent_term, true) of + true -> + CowboyOptions1; + _ -> + CowboyOptions1#{env => #{dispatch => Dispatch}} + end, + + case maps:get(use_ssl, Configuration, false) of + false -> + case cowboy:start_clear( + ?NOVA_LISTENER, + [{port, Port}, + {ip, Host}], + CowboyOptions2) of {ok, _Pid} -> - ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => Port}), + nova:set_env(?COWBOY_LISTENERS, [{Host, Port}|Listeners]), {ok, BootstrapApp, Host, Port}; Error -> - ?LOG_ERROR(#{msg => <<"Could not start cowboy with SSL">>, reason => Error}), Error end; - CACert -> - Cert = maps:get(cert, Configuration), - Port = maps:get(ssl_port, Configuration, ?NOVA_STD_SSL_PORT), - ?LOG_DEPRECATED(<<"0.10.3">>, <<"Use of use_ssl is deprecated, use ssl instead">>), + _ -> + SSLPort = maps:get(ssl_port, Configuration, ?NOVA_STD_SSL_PORT), + SSLOptions = maps:get(ssl_options, Configuration, #{}), + TransportOpts = maps:put(port, SSLPort, SSLOptions), + TransportOpts1 = maps:put(ip, Host, TransportOpts), + case cowboy:start_tls( - ?NOVA_LISTENER, [ - {port, Port}, - {ip, Host}, - {certfile, Cert}, - {cacertfile, CACert} - ], - CowboyOptions2) of + ?NOVA_LISTENER, maps:to_list(TransportOpts1), CowboyOptions2) of {ok, _Pid} -> - ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => Port}), - {ok, BootstrapApp, Host, Port}; + ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => SSLPort}), + nova:set_env(?COWBOY_LISTENERS, [{Host, SSLPort}|Listeners]), + {ok, BootstrapApp, Host, SSLPort}; Error -> + ?LOG_ERROR(#{msg => <<"Could not start cowboy with SSL">>, reason => Error}), Error end end From fa2c964d790e79725f9c8e07703e767831481445 Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Sun, 17 Aug 2025 22:40:34 +0200 Subject: [PATCH 03/21] Add functions for removing an application and list all started applications --- src/nova_sup.erl | 70 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/src/nova_sup.erl b/src/nova_sup.erl index 7e98b5d..b88f891 100644 --- a/src/nova_sup.erl +++ b/src/nova_sup.erl @@ -10,7 +10,9 @@ %% API -export([ start_link/0, - add_application/2 + add_application/2, + remove_application/1, + get_started_applications/0 ]). %% Supervisor callbacks @@ -21,11 +23,21 @@ -define(SERVER, ?MODULE). --define(NOVA_LISTENER, nova_listener). +-define(NOVA_LISTENER, fun(LApp, LPort) -> list_to_atom(atom_to_list(LApp) ++ integer_to_list(LPort)) end). -define(NOVA_STD_PORT, 8080). -define(NOVA_STD_SSL_PORT, 8443). +-define(NOVA_SUP_TABLE, nova_sup_table). -define(COWBOY_LISTENERS, cowboy_listeners). + +-record(nova_server, { + app :: atom(), + host :: inet:ip_address(), + port :: number(), + listener :: ranch:ref() + }). + + %%%=================================================================== %%% API functions %%%=================================================================== @@ -54,6 +66,40 @@ start_link() -> add_application(App, Configuration) -> setup_cowboy(App, Configuration). +%%-------------------------------------------------------------------- +%% @doc +%% Get all started Nova applications. This will return a list of +%% #nova_server{} records that contains the application name, host, port +%% and listener reference. +%% +%% @end +%%-------------------------------------------------------------------- +-spec get_started_applications() -> [#{app => atom(), host => inet:ip_address(), port => number()}]. +get_started_applications() -> + %% Fetch all started applications from the ETS table + Apps = ets:tab2list(?NOVA_SUP_TABLE), + [ #{app => App, host => Host, port => Port} || + #nova_server{app = App, host = Host, port = Port} <- Apps ]. + +%%-------------------------------------------------------------------- +%% @doc +%% Remove a Nova application. This will stop the cowboy listener so request +%% to that application will not be handled anymore. +%% +%% @end +%%-------------------------------------------------------------------- +remove_application(App) -> + case ets:lookup(?NOVA_SUP_TABLE, App) of + [] -> + ?LOG_ERROR(#{msg => <<"Application not found">>, app => App}), + {error, not_found}; + [#nova_server{listener = Listener}] -> + ?LOG_NOTICE(#{msg => <<"Stopping cowboy listener">>, app => App, listener => Listener}), + cowboy:stop_listener(Listener), + ets:delete(?NOVA_SUP_TABLE, App), + ok + end. + %%%=================================================================== %%% Supervisor callbacks %%%=================================================================== @@ -69,6 +115,9 @@ add_application(App, Configuration) -> %% @end %%-------------------------------------------------------------------- init([]) -> + %% Initialize the ETS table for application state + ets:new(?NOVA_SUP_TABLE, [named_table, protected, set]), + %% This is a bit ugly, but we need to do this anyhow(?) SupFlags = #{strategy => one_for_one, intensity => 1, @@ -206,12 +255,18 @@ start_cowboy(BootstrapApp, Configuration) -> case maps:get(use_ssl, Configuration, false) of false -> case cowboy:start_clear( - ?NOVA_LISTENER, + ?NOVA_LISTENER(BootstrapApp, Port), [{port, Port}, {ip, Host}], CowboyOptions2) of {ok, _Pid} -> nova:set_env(?COWBOY_LISTENERS, [{Host, Port}|Listeners]), + ets:insert(?NOVA_SUP_TABLE, #nova_server{ + app = BootstrapApp, + host = Host, + port = Port, + listener = ?NOVA_LISTENER(BootstrapApp, Port) + }), {ok, BootstrapApp, Host, Port}; Error -> Error @@ -223,9 +278,16 @@ start_cowboy(BootstrapApp, Configuration) -> TransportOpts1 = maps:put(ip, Host, TransportOpts), case cowboy:start_tls( - ?NOVA_LISTENER, maps:to_list(TransportOpts1), CowboyOptions2) of + ?NOVA_LISTENER(BootstrapApp, SSLPort), + maps:to_list(TransportOpts1), CowboyOptions2) of {ok, _Pid} -> ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => SSLPort}), + ets:insert(?NOVA_SUP_TABLE, #nova_server{ + app = BootstrapApp, + host = Host, + port = SSLPort, + listener = ?NOVA_LISTENER(BootstrapApp, SSLPort) + }), nova:set_env(?COWBOY_LISTENERS, [{Host, SSLPort}|Listeners]), {ok, BootstrapApp, Host, SSLPort}; Error -> From 77174e7750c927d7aff91101a01efec3b952901a Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Sat, 27 Sep 2025 21:24:03 +0200 Subject: [PATCH 04/21] Docs and a new implementation of routing_trie --- guides/multi-app.md | 11 ++ src/nova_router.erl | 23 ++- src/nova_sup.erl | 1 + src/routing_trie.erl | 347 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/routing_trie.erl diff --git a/guides/multi-app.md b/guides/multi-app.md index 89b9b41..b858b53 100644 --- a/guides/multi-app.md +++ b/guides/multi-app.md @@ -32,3 +32,14 @@ There's currently two different options available and they works in the same way ]} ... ``` + +## Pragmatically starting other nova applications + +### Starting an application + +You can also start other nova applications pragmatically by calling `nova_sup:add_application/2` to add another nova application to your supervision tree. The routes will automatically be added to the routing-module. + + +## Stopping an application + +To stop a nova application you can call `nova_sup:remove_application/1` with the name of the application you want to stop. Use this with caution since calling this method all routes for all other applications will be removed and re-added in order to filter out the one removed. diff --git a/src/nova_router.erl b/src/nova_router.erl index ddac44b..580e539 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -31,7 +31,9 @@ %% Modulates the routes-table add_routes/1, - add_routes/2 + add_routes/2, + remove_routes_for_app/1 + ]). -include_lib("routing_tree/include/routing_tree.hrl"). @@ -146,6 +148,25 @@ lookup_url(Host, Path, Method) -> lookup_url(Host, Path, Method, Dispatch) -> routing_tree:lookup(Host, Path, Method, Dispatch). +%%-------------------------------------------------------------------- +%% @doc +%% Remove all routes associated with the given application. Returns either +%% {ok, Amount} where Amount is the number of removed routes or {error, Reason}. +%% @end +%%-------------------------------------------------------------------- +-spec remove_routes_for_app(Application :: atom()) -> {ok, RemovedRoutes :: number()} | + {error, Reason :: term()}. +remove_routes_for_app(Application) -> + StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + Dispatch = StorageBackend:get(nova_dispatch), + CompiledApps = StorageBackend:get(?NOVA_APPS, []), + %% Remove the app from the compiled apps + CompiledApps0 = lists:filter(fun({App, _Prefix}) -> App =/= Application end, CompiledApps), + %% Remove all routes for this app + Dispatch1 = routing_tree:remove_app(Application, Dispatch), + StorageBackend:put(nova_dispatch, Dispatch1), + StorageBackend:put(?NOVA_APPS, CompiledApps0), + ok. %%-------------------------------------------------------------------- %% @doc diff --git a/src/nova_sup.erl b/src/nova_sup.erl index b88f891..d78f141 100644 --- a/src/nova_sup.erl +++ b/src/nova_sup.erl @@ -97,6 +97,7 @@ remove_application(App) -> ?LOG_NOTICE(#{msg => <<"Stopping cowboy listener">>, app => App, listener => Listener}), cowboy:stop_listener(Listener), ets:delete(?NOVA_SUP_TABLE, App), + %% Now we need to remove all routes associated with this listener ok end. diff --git a/src/routing_trie.erl b/src/routing_trie.erl new file mode 100644 index 0000000..c93f310 --- /dev/null +++ b/src/routing_trie.erl @@ -0,0 +1,347 @@ +%%%------------------------------------------------------------------- +%%% Path trie with wildcards (":var"), HTTP method comparator, +%%% binding capture, and rich safe_insert/3 conflicts. +%%%------------------------------------------------------------------- +-module(routing_trie). + +-export([ + new/0, + + %% Backward-compatible (default method = ALL) + insert/2, % insert(Path, Trie) + safe_insert/2, % safe_insert(Path, Trie) + member/2, % member(Path, Trie) + match/2, % match(Path, Trie) -> {ok, Binds} | error + + %% Method-aware APIs + insert/3, % insert(Method, Path, Trie) + safe_insert/3, % safe_insert(Method, Path, Trie) + member/3, % member(Method, Path, Trie) + match/3, % match(Method, Path, Trie) -> {ok, Binds} | error + + find/2, + to_list/1 +]). + +-opaque trie() :: node(). +-opaque node() :: #{ + children := #{ child_key() => node() }, + terminal_methods := methods_set() %% set of methods for which this node is terminal +}. +-type child_key() :: binary() | {wild, binary()}. +-type method() :: get | post | put | delete | patch | options | all. +-type methods_set() :: #{ method() => true }. + +-export_type([trie/0, node/0, method/0]). + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +new() -> + #{children => #{}, terminal_methods => #{}}. + +%% --- Back-compat: default to ALL ----------------------------------- +insert(Path, Trie) -> + insert(all, Path, Trie). + +safe_insert(Path, Trie) -> + safe_insert(all, Path, Trie). + +member(Path, Trie) -> + member(all, Path, Trie). + +match(Path, Trie) -> + match(all, Path, Trie). + +%% --- Method-aware insert/member/match ------------------------------- + +insert(Method0, Path, Trie) -> + M = norm_method(Method0), + Segs = segs_for_insert(Path), + insert_segs(M, Segs, Trie). + +-spec safe_insert(method_in(), iodata(), trie()) -> + {ok, trie()} | + {error, conflict, #{reason := atom(), + at := [child_key()], + existing := child_key() | undefined, + incoming := child_key() | undefined, + conflicts_with := binary(), + incoming_path := binary(), + method := method(), + existing_methods := [method()]}}. +safe_insert(Method0, Path, Trie) -> + M = norm_method(Method0), + Segs = segs_for_insert(Path), + safe_insert_segs(M, Segs, Trie, [], Segs). + +member(Method0, Path, Trie) -> + M = norm_method(Method0), + case find(Path, Trie) of + {ok, Node} -> method_member(M, Node); + error -> false + end. + +%% match(Method, ConcretePath, Trie) -> {ok, Binds} | error +match(Method0, Path, Trie) -> + M = norm_method(Method0), + Segs = segs_for_match(Path), + case do_match(Segs, Trie, #{}) of + {ok, Node, Binds} -> + case method_member(M, Node) of + true -> + {ok, Binds}; + _ -> + error + end; + _ -> + error + end. + +find(Path, Trie) -> + Segs = segs_for_insert(Path), + case descend_pattern(Segs, Trie) of + undefined -> error; + Node -> {ok, Node} + end. + +%% Return ["METHOD /path", ...] +to_list(Trie) -> + gather(Trie, [], []). + +%%-------------------------------------------------------------------- +%% Internals +%%-------------------------------------------------------------------- + +-type method_in() :: method() | binary() | list() | atom(). + +norm_method(M) when is_atom(M) -> + norm_method(atom_to_list(M)); +norm_method(M) when is_list(M) -> + norm_method(list_to_binary(string:uppercase(M))); +norm_method(<>) -> + case M of + <<"GET">> -> get; + <<"POST">> -> post; + <<"PUT">> -> put; + <<"DELETE">> -> delete; + <<"PATCH">> -> patch; + <<"OPTIONS">> -> options; + <<"ALL">> -> all + end. + +%% --- Segment normalization ----------------------------------------- + +segs_for_insert(Path) when is_list(Path) -> + segs_for_insert(list_to_binary(Path)); +segs_for_insert(Path) when is_binary(Path) -> + Parts = [S || S <- binary:split(Path, <<"/">>, [global]), S =/= <<>>], + [<<"/">> | [ to_key(S) || S <- Parts ]]. + +segs_for_match(Path) when is_list(Path) -> + segs_for_match(list_to_binary(Path)); +segs_for_match(Path) when is_binary(Path) -> + Parts = [S || S <- binary:split(Path, <<"/">>, [global]), S =/= <<>>], + [<<"/">> | Parts]. + +to_key(<<":", Rest/binary>>) -> {wild, Rest}; +to_key(Bin) -> Bin. + +render_key({wild, Name}) -> <<":", Name/binary>>; +render_key(Bin) -> Bin. + +render_path(Segs) -> + case Segs of + [] -> <<"/">>; + [<<"/">>] -> <<"/">>; + [<<"/">> | Rest] -> <<"/", (binary:join([render_key(S) || S <- Rest], <<"/">>))/binary>>; + _ -> binary:join([render_key(S) || S <- Segs], <<"/">>) + end. + +render_method(M) -> + case M of + get -> <<"GET">>; + post -> <<"POST">>; + put -> <<"PUT">>; + delete -> <<"DELETE">>; + patch -> <<"PATCH">>; + options -> <<"OPTIONS">>; + all -> <<"ALL">> + end. + +%% --- Terminal helpers ---------------------------------------------- + +terminal_add(M, Node0=#{terminal_methods := Ms0}) -> + Node0#{terminal_methods := Ms0#{ M => true }}. + +method_member(M, #{terminal_methods := Ms}) -> + case M of + all -> maps:is_key(all, Ms) orelse (maps:size(Ms) > 0); + _ -> maps:is_key(M, Ms) orelse maps:is_key(all, Ms) + end. + +methods_list(#{terminal_methods := Ms}) -> + [K || {K, true} <- maps:to_list(Ms)]. + +%% --- Permissive insert (kept for convenience) ---------------------- + +insert_segs(M, [], N0) -> + terminal_add(M, N0); +insert_segs(M, [K | Rest], N0) -> + Cs0 = maps:get(children, N0), + Child0 = maps:get(K, Cs0, new()), + Child1 = insert_segs(M, Rest, Child0), + N0#{children := maps:put(K, Child1, Cs0)}. + +descend_pattern([], N) -> + N; +descend_pattern([K | Rest], N) -> + Cs = maps:get(children, N), + case maps:find(K, Cs) of + error -> undefined; + {ok, Child} -> descend_pattern(Rest, Child) + end. + +%% --- Safe insert with method-aware conflicts ----------------------- + +%% Args: +%% - M: method() +%% - SegsToInsert, N0, Prefix, Full same as before +safe_insert_segs(M, [], N0, Prefix, Full) -> + Ms = methods_list(N0), + case lists:member(M, Ms) of + true -> + {error, conflict, #{ + reason => duplicate_pattern, + at => Prefix, + existing => undefined, + incoming => undefined, + conflicts_with => render_path(Prefix), + incoming_path => render_path(Full), + method => M, + existing_methods => Ms + }}; + _ -> + case lists:member(all, Ms) of + true when M =/= all -> + {error, conflict, #{ + reason => duplicate_due_to_all, + at => Prefix, + existing => undefined, + incoming => undefined, + conflicts_with => render_path(Prefix), + incoming_path => render_path(Full), + method => M, + existing_methods => Ms + }}; + _ when M =:= all, Ms =/= [] -> + {error, conflict, #{ + reason => duplicate_due_to_existing_methods, + at => Prefix, + existing => undefined, + incoming => undefined, + conflicts_with => render_path(Prefix), + incoming_path => render_path(Full), + method => all, + existing_methods => Ms + }}; + _ -> + {ok, terminal_add(M, N0)} + end + end; + +safe_insert_segs(M, [K = {wild, NewVar} | Rest], N0, Prefix, Full) -> + Cs0 = maps:get(children, N0), + ExistingWild = find_wild_child(Cs0), + case ExistingWild of + none -> + Child0 = new(), + case safe_insert_segs(M, Rest, Child0, Prefix ++ [K], Full) of + {ok, Child1} -> + {ok, N0#{children := maps:put(K, Child1, Cs0)}}; + {error, conflict, Info} -> + {error, conflict, Info} + end; + {wild, ExistingVar, ChildN} -> + if ExistingVar =:= NewVar -> + case safe_insert_segs(M, Rest, ChildN, Prefix ++ [K], Full) of + {ok, Child1} -> + {ok, N0#{children := maps:put({wild, ExistingVar}, Child1, Cs0)}}; + {error, conflict, Info} -> + {error, conflict, Info} + end; + true -> + {error, conflict, #{ + reason => wildcard_name_conflict, + at => Prefix, + existing => {wild, ExistingVar}, + incoming => {wild, NewVar}, + conflicts_with => render_path(Prefix ++ [{wild, ExistingVar}]), + incoming_path => render_path(Prefix ++ [K] ++ Rest), + method => M, + existing_methods => methods_list(ChildN) + }} + end + end; + +safe_insert_segs(M, [K | Rest], N0, Prefix, Full) -> + Cs0 = maps:get(children, N0), + Child0 = maps:get(K, Cs0, new()), + case safe_insert_segs(M, Rest, Child0, Prefix ++ [K], Full) of + {ok, Child1} -> + {ok, N0#{children := maps:put(K, Child1, Cs0)}}; + {error, conflict, Info} -> + {error, conflict, Info} + end. + +find_wild_child(Cs) -> + maps:fold( + fun + ({wild, Var}, Child, none) -> {wild, Var, Child}; + (_K, _Child, Acc) -> Acc + end, none, Cs). + +%% --- Matching with precedence & backtracking ----------------------- + +do_match([], N, Binds) -> + {ok, N, Binds}; +do_match([Seg | Rest], N, Binds0) -> + Cs = maps:get(children, N), + + %% 1) Exact first + Exact = case maps:find(Seg, Cs) of + {ok, C} -> case do_match(Rest, C, Binds0) of + error -> error; + Ok -> Ok + end; + error -> error + end, + case Exact of + {ok, _, _} -> Exact; + error -> + %% 2) Wildcard fallback + case find_wild_child(Cs) of + none -> error; + {wild, VarName, C0} -> + do_match(Rest, C0, Binds0#{ VarName => Seg }) + end + end. + +%% --- Enumeration ---------------------------------------------------- + +gather(N, AccSegs, AccOut) -> + Ms = methods_list(N), + AccOut1 = + case Ms of + [] -> AccOut; + _ -> + Path = render_path(AccSegs), + MethodLines = [<< (render_method(M))/binary, " ", Path/binary >> || M <- Ms], + MethodLines ++ AccOut + end, + Cs = maps:get(children, N), + maps:fold( + fun(K, Child, Out) -> + gather(Child, AccSegs ++ [K], Out) + end, AccOut1, Cs). From 315ed64887d0b62595f6e8405f1f33dc0ee94473 Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Tue, 9 Dec 2025 19:02:31 +0100 Subject: [PATCH 05/21] Intermediate commit --- src/nova_router.erl | 28 +---- src/routing_trie.erl | 252 +++++++++++++++++++++++++++++-------------- 2 files changed, 175 insertions(+), 105 deletions(-) diff --git a/src/nova_router.erl b/src/nova_router.erl index 580e539..177a228 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -27,13 +27,11 @@ %% Fetch information about the routing table plugins/0, - compiled_apps/0 + compiled_apps/0, %% Modulates the routes-table add_routes/1, - add_routes/2, - remove_routes_for_app/1 - + add_routes/2 ]). -include_lib("routing_tree/include/routing_tree.hrl"). @@ -69,8 +67,7 @@ compile(Apps) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), StoredDispatch = StorageBackend:get(nova_dispatch, - routing_tree:new(#{use_strict => UseStrict, - convert_to_binary => true})), + routing_trie:new(#{strict => UseStrict}), Dispatch = compile(Apps, StoredDispatch, #{}), %% Write the updated dispatch to storage StorageBackend:put(nova_dispatch, Dispatch), @@ -148,25 +145,6 @@ lookup_url(Host, Path, Method) -> lookup_url(Host, Path, Method, Dispatch) -> routing_tree:lookup(Host, Path, Method, Dispatch). -%%-------------------------------------------------------------------- -%% @doc -%% Remove all routes associated with the given application. Returns either -%% {ok, Amount} where Amount is the number of removed routes or {error, Reason}. -%% @end -%%-------------------------------------------------------------------- --spec remove_routes_for_app(Application :: atom()) -> {ok, RemovedRoutes :: number()} | - {error, Reason :: term()}. -remove_routes_for_app(Application) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - Dispatch = StorageBackend:get(nova_dispatch), - CompiledApps = StorageBackend:get(?NOVA_APPS, []), - %% Remove the app from the compiled apps - CompiledApps0 = lists:filter(fun({App, _Prefix}) -> App =/= Application end, CompiledApps), - %% Remove all routes for this app - Dispatch1 = routing_tree:remove_app(Application, Dispatch), - StorageBackend:put(nova_dispatch, Dispatch1), - StorageBackend:put(?NOVA_APPS, CompiledApps0), - ok. %%-------------------------------------------------------------------- %% @doc diff --git a/src/routing_trie.erl b/src/routing_trie.erl index c93f310..91c2a88 100644 --- a/src/routing_trie.erl +++ b/src/routing_trie.erl @@ -5,77 +5,145 @@ -module(routing_trie). -export([ - new/0, - - %% Backward-compatible (default method = ALL) - insert/2, % insert(Path, Trie) - safe_insert/2, % safe_insert(Path, Trie) - member/2, % member(Path, Trie) - match/2, % match(Path, Trie) -> {ok, Binds} | error - - %% Method-aware APIs - insert/3, % insert(Method, Path, Trie) - safe_insert/3, % safe_insert(Method, Path, Trie) - member/3, % member(Method, Path, Trie) - match/3, % match(Method, Path, Trie) -> {ok, Binds} | error - - find/2, - to_list/1 -]). - --opaque trie() :: node(). --opaque node() :: #{ - children := #{ child_key() => node() }, - terminal_methods := methods_set() %% set of methods for which this node is terminal -}. + new/0, + new/1, + + %% Method-agnostic APIs (Using method set to `all`) + insert/2, + member/2, + match/2, + + %% Method-aware APIs + insert/3, + insert/4, + member/3, + match/3, + + find/2, + to_list/1 + ]). + +-opaque trie() :: trie_node(). +-opaque trie_node() :: #{ + options := map(), %% options for this node + children := #{ child_key() => trie_node() }, + terminal_methods := methods_set() %% set of methods for which this node is terminal + }. -type child_key() :: binary() | {wild, binary()}. + -type method() :: get | post | put | delete | patch | options | all. -type methods_set() :: #{ method() => true }. +-type method_in() :: method() | binary() | list() | atom(). + +-type conflict() :: #{reason := atom(), + at := [child_key()], + existing := child_key() | undefined, + incoming := child_key() | undefined, + conflicts_with := binary(), + incoming_path := binary(), + method := method(), + existing_methods := [method()]}. --export_type([trie/0, node/0, method/0]). +-export_type([trie/0, trie_node/0, method/0]). %%-------------------------------------------------------------------- %% API %%-------------------------------------------------------------------- +%%-------------------------------------------------------------------- +%% @doc +%% Creates a new empty trie. +%% @end +%%-------------------------------------------------------------------- +-spec new() -> trie(). new() -> - #{children => #{}, terminal_methods => #{}}. + new(#{}). + +%%-------------------------------------------------------------------- +%% @doc +%% Creates a new empty trie with options. +%% Options: +%% - any key/value pairs to be stored in the root node options map +%% @end +%%-------------------------------------------------------------------- +-spec new(map()) -> trie(). +new(Opts) when is_map(Opts) -> + #{children => #{}, terminal_methods => #{}, options => Opts}. + -%% --- Back-compat: default to ALL ----------------------------------- +%%-------------------------------------------------------------------- +%% @doc +%% Inserts a path into the trie. Same as calling `insert(all, Path, Trie, #{})` +%% @end +%%-------------------------------------------------------------------- +-spec insert(iodata(), trie()) -> {ok, trie()} | {error, conflict, conflict()}. insert(Path, Trie) -> - insert(all, Path, Trie). + Opts = maps:get(options, Trie, #{}), + insert(all, Path, Trie, Opts). -safe_insert(Path, Trie) -> - safe_insert(all, Path, Trie). +%%-------------------------------------------------------------------- +%% @doc +%% Inserts a path into the trie with options. Same as calling +%% `insert(all, Path, Trie, Opts)`. +%% Options: +%% - strict: boolean() (default: false) +%% If true, performs a "safe" insert that checks for conflicts +%% (wildcard name conflicts, duplicate patterns, etc). +%% @end +-spec insert(iodata(), trie(), map()) -> {ok, trie()} | {error, conflict, conflict()}. +insert(Path, Trie, Opts) -> + Opts0 = maps:get(options, Trie, #{}), + Opts1 = maps:merge(Opts0, Opts), + insert(all, Path, Trie, Opts1). -member(Path, Trie) -> - member(all, Path, Trie). +%%-------------------------------------------------------------------- +%% @doc +%% Inserts a method+path into the trie with options. If a node already +%% exists for the given path and method not exists already, the node is added. +%% Otherwise it will return an error with information about the conflict if +%% not `overwrite` option is set to true. +%% Options: +%% - strict: boolean() (default: false) +%% If true, performs a "safe" insert that checks for conflicts +%% (wildcard name conflicts, duplicate patterns, etc). +%% - overwrite: boolean() (default: false) +%% If true, will overwrite existing method at the given path if exists. +%% @end +-spec insert(method_in(), iodata(), trie(), map()) -> + {ok, trie()} | {error, conflict, conflict()}. +insert(Method, Path, Trie, Opts) -> + Opts0 = maps:get(options, Trie, #{}), + Opts1 = maps:merge(Opts0, Opts), + Trie0 = Trie#{options => Opts1}, + + M = norm_method(Method), + Segs = segs_for_insert(Path), -match(Path, Trie) -> - match(all, Path, Trie). + case maps:get(strict, Opts1, false) of + true -> + %% Call safe insert which checks for conflicts + safe_insert_segs(M, Segs, Trie0, [], Segs); + _ -> + insert_segs(M, Segs, Trie0) + end. -%% --- Method-aware insert/member/match ------------------------------- -insert(Method0, Path, Trie) -> - M = norm_method(Method0), - Segs = segs_for_insert(Path), - insert_segs(M, Segs, Trie). - --spec safe_insert(method_in(), iodata(), trie()) -> - {ok, trie()} | - {error, conflict, #{reason := atom(), - at := [child_key()], - existing := child_key() | undefined, - incoming := child_key() | undefined, - conflicts_with := binary(), - incoming_path := binary(), - method := method(), - existing_methods := [method()]}}. -safe_insert(Method0, Path, Trie) -> - M = norm_method(Method0), - Segs = segs_for_insert(Path), - safe_insert_segs(M, Segs, Trie, [], Segs). +%%-------------------------------------------------------------------- +%% @doc +%% Checks if a path exists in the trie. Same as calling `member(all, Path, Trie)`. +%% @end +%%-------------------------------------------------------------------- +-spec member(iodata(), trie()) -> boolean(). +member(Path, Trie) -> + member(all, Path, Trie). + +%%-------------------------------------------------------------------- +%% @doc +%% Checks if a method+path exists in the trie. +%% @end +%%-------------------------------------------------------------------- +-spec member(method_in(), iodata(), trie()) -> boolean(). member(Method0, Path, Trie) -> M = norm_method(Method0), case find(Path, Trie) of @@ -83,7 +151,23 @@ member(Method0, Path, Trie) -> error -> false end. -%% match(Method, ConcretePath, Trie) -> {ok, Binds} | error +%%-------------------------------------------------------------------- +%% @doc +%% Matches a concrete path against the trie, returning bindings if +%% matched. Same as calling `match(all, Path, Trie)`. +%% @end +%%-------------------------------------------------------------------- +-spec match(iodata(), trie()) -> {ok, #{binary() => binary()}} | error. +match(Path, Trie) -> + match(all, Path, Trie). + +%%-------------------------------------------------------------------- +%% @doc +%% Matches a concrete method+path against the trie, returning bindings if +%% matched. +%% @end +%%-------------------------------------------------------------------- +-spec match(method_in(), iodata(), trie()) -> {ok, #{binary() => binary()}} | error. match(Method0, Path, Trie) -> M = norm_method(Method0), Segs = segs_for_match(Path), @@ -99,6 +183,12 @@ match(Method0, Path, Trie) -> error end. +%%-------------------------------------------------------------------- +%% @doc +%% Finds the node for a given path, regardless of method. +%% @end +%%-------------------------------------------------------------------- +-spec find(iodata(), trie()) -> {ok, trie_node()} | error. find(Path, Trie) -> Segs = segs_for_insert(Path), case descend_pattern(Segs, Trie) of @@ -106,16 +196,18 @@ find(Path, Trie) -> Node -> {ok, Node} end. -%% Return ["METHOD /path", ...] +%%-------------------------------------------------------------------- +%% @doc +%% Returns a list of all method+path combinations in the trie. +%% @end +%%-------------------------------------------------------------------- +-spec to_list(trie()) -> [binary()]. to_list(Trie) -> gather(Trie, [], []). %%-------------------------------------------------------------------- %% Internals %%-------------------------------------------------------------------- - --type method_in() :: method() | binary() | list() | atom(). - norm_method(M) when is_atom(M) -> norm_method(atom_to_list(M)); norm_method(M) when is_list(M) -> @@ -128,7 +220,7 @@ norm_method(<>) -> <<"DELETE">> -> delete; <<"PATCH">> -> patch; <<"OPTIONS">> -> options; - <<"ALL">> -> all + _ -> all end. %% --- Segment normalization ----------------------------------------- @@ -136,13 +228,13 @@ norm_method(<>) -> segs_for_insert(Path) when is_list(Path) -> segs_for_insert(list_to_binary(Path)); segs_for_insert(Path) when is_binary(Path) -> - Parts = [S || S <- binary:split(Path, <<"/">>, [global]), S =/= <<>>], + Parts = [S || S <- binary:split(Path, <<"/">>, [global, trim]), S =/= <<>>], [<<"/">> | [ to_key(S) || S <- Parts ]]. segs_for_match(Path) when is_list(Path) -> segs_for_match(list_to_binary(Path)); segs_for_match(Path) when is_binary(Path) -> - Parts = [S || S <- binary:split(Path, <<"/">>, [global]), S =/= <<>>], + Parts = [S || S <- binary:split(Path, <<"/">>, [global, trim]), S =/= <<>>], [<<"/">> | Parts]. to_key(<<":", Rest/binary>>) -> {wild, Rest}; @@ -213,15 +305,15 @@ safe_insert_segs(M, [], N0, Prefix, Full) -> case lists:member(M, Ms) of true -> {error, conflict, #{ - reason => duplicate_pattern, - at => Prefix, - existing => undefined, - incoming => undefined, - conflicts_with => render_path(Prefix), - incoming_path => render_path(Full), - method => M, - existing_methods => Ms - }}; + reason => duplicate_pattern, + at => Prefix, + existing => undefined, + incoming => undefined, + conflicts_with => render_path(Prefix), + incoming_path => render_path(Full), + method => M, + existing_methods => Ms + }}; _ -> case lists:member(all, Ms) of true when M =/= all -> @@ -273,15 +365,15 @@ safe_insert_segs(M, [K = {wild, NewVar} | Rest], N0, Prefix, Full) -> end; true -> {error, conflict, #{ - reason => wildcard_name_conflict, - at => Prefix, - existing => {wild, ExistingVar}, - incoming => {wild, NewVar}, - conflicts_with => render_path(Prefix ++ [{wild, ExistingVar}]), - incoming_path => render_path(Prefix ++ [K] ++ Rest), - method => M, - existing_methods => methods_list(ChildN) - }} + reason => wildcard_name_conflict, + at => Prefix, + existing => {wild, ExistingVar}, + incoming => {wild, NewVar}, + conflicts_with => render_path(Prefix ++ [{wild, ExistingVar}]), + incoming_path => render_path(Prefix ++ [K] ++ Rest), + method => M, + existing_methods => methods_list(ChildN) + }} end end; @@ -300,7 +392,7 @@ find_wild_child(Cs) -> fun ({wild, Var}, Child, none) -> {wild, Var, Child}; (_K, _Child, Acc) -> Acc - end, none, Cs). + end, none, Cs). %% --- Matching with precedence & backtracking ----------------------- From 0f160dcfda655de4d9302b2d41ef997ecd5fea7c Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Tue, 9 Dec 2025 19:04:53 +0100 Subject: [PATCH 06/21] Remove unused format_status callback --- src/nova_request.erl | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/nova_request.erl b/src/nova_request.erl index 0629c03..f3fdd24 100644 --- a/src/nova_request.erl +++ b/src/nova_request.erl @@ -22,8 +22,7 @@ handle_cast/2, handle_info/2, terminate/2, - code_change/3, - format_status/2 + code_change/3 ]). -define(SERVER, ?MODULE). @@ -139,19 +138,6 @@ terminate(_Reason, _State) -> code_change(_OldVsn, State, _Extra) -> {ok, State}. -%%-------------------------------------------------------------------- -%% @private -%% @doc -%% This function is called for changing the form and appearance -%% of gen_server status when it is returned from sys:get_status/1,2 -%% or when it appears in termination error logs. -%% @end -%%-------------------------------------------------------------------- --spec format_status(Opt :: normal | terminate, - Status :: list()) -> Status :: term(). -format_status(_Opt, Status) -> - Status. - %%%=================================================================== %%% Internal functions %%%=================================================================== From d0826724c1bf55a8455d82a3c2a72ab7a6492368 Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Tue, 16 Dec 2025 19:38:09 +0100 Subject: [PATCH 07/21] Add tests and functionality to router --- src/nova_router.erl | 4 +- src/routing_trie.erl | 475 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 381 insertions(+), 98 deletions(-) diff --git a/src/nova_router.erl b/src/nova_router.erl index 177a228..945bb96 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -67,7 +67,7 @@ compile(Apps) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), StoredDispatch = StorageBackend:get(nova_dispatch, - routing_trie:new(#{strict => UseStrict}), + routing_trie:new(#{strict => UseStrict})), Dispatch = compile(Apps, StoredDispatch, #{}), %% Write the updated dispatch to storage StorageBackend:put(nova_dispatch, Dispatch), @@ -79,7 +79,7 @@ compile(Apps) -> execute(Req = #{host := Host, path := Path, method := Method}, Env) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), Dispatch = StorageBackend:get(nova_dispatch), - case routing_tree:lookup(Host, Path, Method, Dispatch) of + case routing_trie:find(Host, Path, Method, Dispatch) of {error, not_found} -> logger:debug("Path ~p not found for ~p in ~p", [Path, Method, Host]), render_status_page('_', 404, #{error => "Not found in path"}, Req, Env); diff --git a/src/routing_trie.erl b/src/routing_trie.erl index 91c2a88..2841037 100644 --- a/src/routing_trie.erl +++ b/src/routing_trie.erl @@ -1,36 +1,50 @@ -%%%------------------------------------------------------------------- -%%% Path trie with wildcards (":var"), HTTP method comparator, -%%% binding capture, and rich safe_insert/3 conflicts. -%%%------------------------------------------------------------------- -module(routing_trie). -export([ new/0, new/1, - %% Method-agnostic APIs (Using method set to `all`) + %% Exports that does not use methods or host (Defaults to 'all' and '_') insert/2, + insert/3, member/2, match/2, + find/2, - %% Method-aware APIs - insert/3, + %% Method-aware APIs (host defaults to '_') insert/4, member/3, match/3, + find/3, + + %% Host + Method-aware APIs + insert/5, %% Method, Host, Path, Trie, Opts + member/4, %% Method, Host, Path, Trie + match/4, %% Method, Host, Path, Trie + find/4, %% Method, Host, Path, Trie - find/2, to_list/1 ]). --opaque trie() :: trie_node(). +%% Host-aware trie: +%% - 'trie()' is the host root +%% - each host key maps to a per-host trie_node() (routing tree) +-opaque trie() :: #{ + options := map(), %% global/root options + hosts := #{ host_key() => trie_node() } + }. + -opaque trie_node() :: #{ - options := map(), %% options for this node - children := #{ child_key() => trie_node() }, - terminal_methods := methods_set() %% set of methods for which this node is terminal + options := map(), %% options for this node + children := #{ child_key() => trie_node() }, + terminal_methods := methods_set() %% set of methods for which this node is terminal }. + -type child_key() :: binary() | {wild, binary()}. +-type host_key() :: '_' | binary(). +-type host_in() :: '_' | binary() | list() | atom(). + -type method() :: get | post | put | delete | patch | options | all. -type methods_set() :: #{ method() => true }. -type method_in() :: method() | binary() | list() | atom(). @@ -52,7 +66,7 @@ %%-------------------------------------------------------------------- %% @doc -%% Creates a new empty trie. +%% Creates a new empty host-root trie. %% @end %%-------------------------------------------------------------------- -spec new() -> trie(). @@ -61,152 +75,306 @@ new() -> %%-------------------------------------------------------------------- %% @doc -%% Creates a new empty trie with options. +%% Creates a new empty host-root trie with options. %% Options: -%% - any key/value pairs to be stored in the root node options map +%% - any key/value pairs to be stored in the root options map %% @end %%-------------------------------------------------------------------- -spec new(map()) -> trie(). new(Opts) when is_map(Opts) -> + #{options => Opts, hosts => #{}}. + +%% Internal: create a new routing node (per-host trie root or child) +-spec new_node() -> trie_node(). +new_node() -> + new_node(#{}). + +-spec new_node(map()) -> trie_node(). +new_node(Opts) when is_map(Opts) -> #{children => #{}, terminal_methods => #{}, options => Opts}. +%%-------------------------------------------------------------------- +%% Host helpers +%%-------------------------------------------------------------------- + +-spec norm_host(host_in()) -> host_key(). +norm_host('_') -> + '_'; +norm_host(Host) when is_binary(Host) -> + Host; +norm_host(Host) when is_list(Host) -> + list_to_binary(Host); +norm_host(Host) when is_atom(Host) -> + list_to_binary(atom_to_list(Host)). + +-spec get_host_trie(host_key(), trie()) -> trie_node() | undefined. +get_host_trie(Host, Trie) -> + Hosts = maps:get(hosts, Trie, #{}), + maps:get(Host, Hosts, undefined). + %%-------------------------------------------------------------------- %% @doc -%% Inserts a path into the trie. Same as calling `insert(all, Path, Trie, #{})` +%% Inserts a path into the trie. Same as calling +%% `insert(all, '_', Path, Trie, #{})` %% @end %%-------------------------------------------------------------------- --spec insert(iodata(), trie()) -> {ok, trie()} | {error, conflict, conflict()}. +-spec insert(iodata(), trie()) -> + {ok, trie()} | {error, conflict, conflict()}. insert(Path, Trie) -> - Opts = maps:get(options, Trie, #{}), - insert(all, Path, Trie, Opts). + insert(all, '_', Path, Trie, #{}). %%-------------------------------------------------------------------- %% @doc -%% Inserts a path into the trie with options. Same as calling -%% `insert(all, Path, Trie, Opts)`. +%% Inserts a path into the trie with options. +%% Same as calling `insert(all, '_', Path, Trie, Opts)`. %% Options: %% - strict: boolean() (default: false) %% If true, performs a "safe" insert that checks for conflicts %% (wildcard name conflicts, duplicate patterns, etc). +%% - overwrite: boolean() (default: false) +%% If true, will overwrite existing method at the given path if exists. %% @end --spec insert(iodata(), trie(), map()) -> {ok, trie()} | {error, conflict, conflict()}. +%%-------------------------------------------------------------------- +-spec insert(iodata(), trie(), map()) -> + {ok, trie()} | {error, conflict, conflict()}. insert(Path, Trie, Opts) -> - Opts0 = maps:get(options, Trie, #{}), - Opts1 = maps:merge(Opts0, Opts), - insert(all, Path, Trie, Opts1). + insert(all, '_', Path, Trie, Opts). %%-------------------------------------------------------------------- %% @doc -%% Inserts a method+path into the trie with options. If a node already -%% exists for the given path and method not exists already, the node is added. -%% Otherwise it will return an error with information about the conflict if -%% not `overwrite` option is set to true. +%% Inserts a method+path into the trie with options, using catch-all host '_'. +%% If a node already exists for the given path and method does not exist +%% already, the node is added. Otherwise it will return an error with +%% information about the conflict if not `overwrite` option is set to true. +%% @end +%%-------------------------------------------------------------------- +-spec insert(method_in(), iodata(), trie(), map()) -> + {ok, trie()} | {error, conflict, conflict()}. +insert(Method, Path, Trie, Opts) -> + insert(Method, '_', Path, Trie, Opts). + +%%-------------------------------------------------------------------- +%% @doc +%% Inserts a method+host+path into the trie with options. +%% Host: +%% - Concrete host (binary/list/atom) => that host only +%% - '_' (atom) => catch-all host, used as fallback %% Options: %% - strict: boolean() (default: false) -%% If true, performs a "safe" insert that checks for conflicts -%% (wildcard name conflicts, duplicate patterns, etc). %% - overwrite: boolean() (default: false) -%% If true, will overwrite existing method at the given path if exists. %% @end --spec insert(method_in(), iodata(), trie(), map()) -> +%%-------------------------------------------------------------------- +-spec insert(method_in(), host_in(), iodata(), trie(), map()) -> {ok, trie()} | {error, conflict, conflict()}. -insert(Method, Path, Trie, Opts) -> - Opts0 = maps:get(options, Trie, #{}), +insert(Method, HostIn, Path, Trie0 = #{options := RootOpts, hosts := Hosts}, Opts) -> + %% Merge options for this + RootOpts1 = maps:merge(RootOpts, Opts), + Trie1 = Trie0#{options := RootOpts1}, + + HostTrie = + case maps:get(HostIn, Hosts, undefined) of + undefined -> + %% Just create a new host trie with opts + new_node(Opts); + HostTrie0 -> + HostTrie0 + end, + case insert_inner(Method, Path, HostTrie, RootOpts1) of + {ok, HostTrie1} -> + Hosts0 = Hosts#{HostIn => HostTrie1}, + {ok, Trie1#{hosts => Hosts0}}; + {error, conflict, Conf} -> + {error, conflict, Conf}; + HostTrie1 -> + Hosts0 = Hosts#{HostIn => HostTrie1}, + {ok, Trie1#{hosts => Hosts0}} + end. + +%% Internal: insert into a single host's routing trie +-spec insert_inner(method_in(), iodata(), trie_node(), map()) -> + {ok, trie_node()} | {error, conflict, conflict()}. +insert_inner(Method, Path, TrieNode, Opts) -> + Opts0 = maps:get(options, TrieNode, #{}), Opts1 = maps:merge(Opts0, Opts), - Trie0 = Trie#{options => Opts1}, + Trie1 = TrieNode#{options := Opts1}, - M = norm_method(Method), + M = norm_method(Method), Segs = segs_for_insert(Path), case maps:get(strict, Opts1, false) of true -> %% Call safe insert which checks for conflicts - safe_insert_segs(M, Segs, Trie0, [], Segs); + safe_insert_segs(M, Segs, Trie1, [], Segs); _ -> - insert_segs(M, Segs, Trie0) + insert_segs(M, Segs, Trie1) end. - %%-------------------------------------------------------------------- %% @doc -%% Checks if a path exists in the trie. Same as calling `member(all, Path, Trie)`. +%% Checks if a path exists in the trie. +%% Same as calling `member(all, Path, Trie)` (host '_'). %% @end %%-------------------------------------------------------------------- -spec member(iodata(), trie()) -> boolean(). member(Path, Trie) -> member(all, Path, Trie). - %%-------------------------------------------------------------------- %% @doc -%% Checks if a method+path exists in the trie. +%% Checks if a method+path exists in the trie (host '_'). %% @end %%-------------------------------------------------------------------- -spec member(method_in(), iodata(), trie()) -> boolean(). member(Method0, Path, Trie) -> - M = norm_method(Method0), - case find(Path, Trie) of - {ok, Node} -> method_member(M, Node); - error -> false + member(Method0, '_', Path, Trie). + +%%-------------------------------------------------------------------- +%% @doc +%% Checks if a host+method+path exists in the trie. +%% Host: +%% - Concrete host => only that host +%% - '_' => catch-all host +%% @end +%%-------------------------------------------------------------------- +-spec member(method_in(), host_in(), iodata(), trie()) -> boolean(). +member(Method0, HostIn, Path, Trie) -> + case find(Method0, HostIn, Path, Trie) of + {ok, _Node} -> true; + error -> false end. %%-------------------------------------------------------------------- %% @doc %% Matches a concrete path against the trie, returning bindings if -%% matched. Same as calling `match(all, Path, Trie)`. +%% matched. Same as calling `match(all, Path, Trie)` (host '_'). %% @end %%-------------------------------------------------------------------- --spec match(iodata(), trie()) -> {ok, #{binary() => binary()}} | error. +-spec match(iodata(), trie()) -> + {ok, #{binary() => binary()}} | error. match(Path, Trie) -> match(all, Path, Trie). %%-------------------------------------------------------------------- %% @doc -%% Matches a concrete method+path against the trie, returning bindings if -%% matched. +%% Matches a concrete method+path against the trie (host '_'), +%% returning bindings if matched. %% @end %%-------------------------------------------------------------------- --spec match(method_in(), iodata(), trie()) -> {ok, #{binary() => binary()}} | error. +-spec match(method_in(), iodata(), trie()) -> + {ok, #{binary() => binary()}} | error. match(Method0, Path, Trie) -> - M = norm_method(Method0), - Segs = segs_for_match(Path), - case do_match(Segs, Trie, #{}) of - {ok, Node, Binds} -> - case method_member(M, Node) of - true -> - {ok, Binds}; + match(Method0, '_', Path, Trie). + +%%-------------------------------------------------------------------- +%% @doc +%% Matches a concrete host+method+path against the trie, returning bindings +%% if matched. +%% +%% Host: +%% - Concrete host => tries that host first, falls back to '_' if not found +%% - '_' => only routes stored under '_' host +%% @end +%%-------------------------------------------------------------------- +-spec match(method_in(), host_in(), iodata(), trie()) -> + {ok, #{binary() => binary()}} | error. +match(Method0, HostIn, Path, Trie) -> + Host = norm_host(HostIn), + HostTrie = + case get_host_trie(Host, Trie) of + undefined when Host =/= '_' -> + get_host_trie('_', Trie); + T -> + T + end, + case HostTrie of + undefined -> + error; + L -> + M = norm_method(Method0), + Segs = segs_for_match(Path), + case do_match(Segs, L, #{}) of + {ok, Node, Binds} -> + case method_member(M, Node) of + true -> {ok, Binds}; + _ -> error + end; _ -> error - end; - _ -> - error + end end. %%-------------------------------------------------------------------- %% @doc -%% Finds the node for a given path, regardless of method. +%% Finds the node for a given path, regardless of method (host '_'). %% @end %%-------------------------------------------------------------------- -spec find(iodata(), trie()) -> {ok, trie_node()} | error. find(Path, Trie) -> - Segs = segs_for_insert(Path), - case descend_pattern(Segs, Trie) of - undefined -> error; - Node -> {ok, Node} + find(all, '_', Path, Trie). + +%%-------------------------------------------------------------------- +%% @doc +%% Finds the node for a given method+path (host '_'). +%% Returns {ok, Node} only if Method exists at that node. +%% @end +%%-------------------------------------------------------------------- +-spec find(method_in(), iodata(), trie()) -> {ok, trie_node()} | error. +find(Method0, Path, Trie) -> + find(Method0, '_', Path, Trie). + +%%-------------------------------------------------------------------- +%% @doc +%% Finds the node for a given method+host+path. +%% Host resolution: +%% - Exact host map +%% - else, if Host =/= '_' and '_' exists, fall back to '_' +%% - else error +%% @end +%%-------------------------------------------------------------------- +-spec find(method_in(), host_in(), iodata(), trie()) -> {ok, trie_node()} | error. +find(Method0, HostIn, Path, Trie) -> + Host = norm_host(HostIn), + HostTrie = + case get_host_trie(Host, Trie) of + undefined when Host =/= '_' -> + get_host_trie('_', Trie); + T -> + T + end, + case HostTrie of + undefined -> + error; + L -> + Segs = segs_for_insert(Path), + case descend_pattern(Segs, L) of + undefined -> + error; + Node -> + M = norm_method(Method0), + case method_member(M, Node) of + true -> {ok, Node}; + false -> error + end + end end. %%-------------------------------------------------------------------- %% @doc -%% Returns a list of all method+path combinations in the trie. +%% Returns a list of all method+path combinations in the trie +%% (ignores host in the output; host dimension is flattened). %% @end %%-------------------------------------------------------------------- -spec to_list(trie()) -> [binary()]. to_list(Trie) -> - gather(Trie, [], []). + Hosts = maps:get(hosts, Trie, #{}), + maps:fold( + fun(_Host, HostTrie, Acc) -> + gather(HostTrie, [], Acc) + end, [], Hosts). %%-------------------------------------------------------------------- -%% Internals +%% Internal functions %%-------------------------------------------------------------------- norm_method(M) when is_atom(M) -> norm_method(atom_to_list(M)); @@ -223,8 +391,6 @@ norm_method(<>) -> _ -> all end. -%% --- Segment normalization ----------------------------------------- - segs_for_insert(Path) when is_list(Path) -> segs_for_insert(list_to_binary(Path)); segs_for_insert(Path) when is_binary(Path) -> @@ -238,7 +404,7 @@ segs_for_match(Path) when is_binary(Path) -> [<<"/">> | Parts]. to_key(<<":", Rest/binary>>) -> {wild, Rest}; -to_key(Bin) -> Bin. +to_key(Bin) -> Bin. render_key({wild, Name}) -> <<":", Name/binary>>; render_key(Bin) -> Bin. @@ -247,8 +413,11 @@ render_path(Segs) -> case Segs of [] -> <<"/">>; [<<"/">>] -> <<"/">>; - [<<"/">> | Rest] -> <<"/", (binary:join([render_key(S) || S <- Rest], <<"/">>))/binary>>; - _ -> binary:join([render_key(S) || S <- Segs], <<"/">>) + [<<"/">> | Rest] -> + Seg = iolist_to_binary(lists:join(<<"/">>, [render_key(S) || S <- Rest])), + <<"/", (Seg)/binary>>; + _ -> + iolist_to_binary(lists:join(<<"/">>, [render_key(S) || S <- Segs])) end. render_method(M) -> @@ -262,8 +431,6 @@ render_method(M) -> all -> <<"ALL">> end. -%% --- Terminal helpers ---------------------------------------------- - terminal_add(M, Node0=#{terminal_methods := Ms0}) -> Node0#{terminal_methods := Ms0#{ M => true }}. @@ -276,13 +443,12 @@ method_member(M, #{terminal_methods := Ms}) -> methods_list(#{terminal_methods := Ms}) -> [K || {K, true} <- maps:to_list(Ms)]. -%% --- Permissive insert (kept for convenience) ---------------------- - +%% Insert without conflict checking (single-host trie) insert_segs(M, [], N0) -> terminal_add(M, N0); insert_segs(M, [K | Rest], N0) -> Cs0 = maps:get(children, N0), - Child0 = maps:get(K, Cs0, new()), + Child0 = maps:get(K, Cs0, new_node()), Child1 = insert_segs(M, Rest, Child0), N0#{children := maps:put(K, Child1, Cs0)}. @@ -295,11 +461,7 @@ descend_pattern([K | Rest], N) -> {ok, Child} -> descend_pattern(Rest, Child) end. -%% --- Safe insert with method-aware conflicts ----------------------- - -%% Args: -%% - M: method() -%% - SegsToInsert, N0, Prefix, Full same as before +%% Safe insert with conflict checking (single-host trie) safe_insert_segs(M, [], N0, Prefix, Full) -> Ms = methods_list(N0), case lists:member(M, Ms) of @@ -348,7 +510,7 @@ safe_insert_segs(M, [K = {wild, NewVar} | Rest], N0, Prefix, Full) -> ExistingWild = find_wild_child(Cs0), case ExistingWild of none -> - Child0 = new(), + Child0 = new_node(), case safe_insert_segs(M, Rest, Child0, Prefix ++ [K], Full) of {ok, Child1} -> {ok, N0#{children := maps:put(K, Child1, Cs0)}}; @@ -379,7 +541,7 @@ safe_insert_segs(M, [K = {wild, NewVar} | Rest], N0, Prefix, Full) -> safe_insert_segs(M, [K | Rest], N0, Prefix, Full) -> Cs0 = maps:get(children, N0), - Child0 = maps:get(K, Cs0, new()), + Child0 = maps:get(K, Cs0, new_node()), case safe_insert_segs(M, Rest, Child0, Prefix ++ [K], Full) of {ok, Child1} -> {ok, N0#{children := maps:put(K, Child1, Cs0)}}; @@ -394,8 +556,6 @@ find_wild_child(Cs) -> (_K, _Child, Acc) -> Acc end, none, Cs). -%% --- Matching with precedence & backtracking ----------------------- - do_match([], N, Binds) -> {ok, N, Binds}; do_match([Seg | Rest], N, Binds0) -> @@ -403,10 +563,11 @@ do_match([Seg | Rest], N, Binds0) -> %% 1) Exact first Exact = case maps:find(Seg, Cs) of - {ok, C} -> case do_match(Rest, C, Binds0) of - error -> error; - Ok -> Ok - end; + {ok, C} -> + case do_match(Rest, C, Binds0) of + error -> error; + Ok -> Ok + end; error -> error end, case Exact of @@ -420,8 +581,6 @@ do_match([Seg | Rest], N, Binds0) -> end end. -%% --- Enumeration ---------------------------------------------------- - gather(N, AccSegs, AccOut) -> Ms = methods_list(N), AccOut1 = @@ -429,7 +588,8 @@ gather(N, AccSegs, AccOut) -> [] -> AccOut; _ -> Path = render_path(AccSegs), - MethodLines = [<< (render_method(M))/binary, " ", Path/binary >> || M <- Ms], + MethodLines = + [<< (render_method(M))/binary, " ", Path/binary >> || M <- Ms], MethodLines ++ AccOut end, Cs = maps:get(children, N), @@ -437,3 +597,126 @@ gather(N, AccSegs, AccOut) -> fun(K, Child, Out) -> gather(Child, AccSegs ++ [K], Out) end, AccOut1, Cs). + + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +%%-------------------------------------------------------------------- +%% Basic insert / match on default host '_' +%%-------------------------------------------------------------------- + +default_host_insert_and_match_test() -> + T0 = routing_trie:new(), + {ok, T1} = routing_trie:insert(get, <<"/users">>, T0, #{}), + + ?assert(routing_trie:member(get, <<"/users">>, T1)), + ?assertMatch({ok, #{}}, + routing_trie:match(get, <<"/users">>, T1)). + +%%-------------------------------------------------------------------- +%% Wildcard path ("/users/:id") on default host +%%-------------------------------------------------------------------- + +wildcard_path_match_test() -> + T0 = routing_trie:new(), + {ok, T1} = routing_trie:insert(get, <<"/users/:id">>, T0, #{strict => true}), + + ?assertMatch({ok, #{<<"id">> := <<"42">>}}, + routing_trie:match(get, <<"/users/42">>, T1)), + ?assertMatch({ok, #{<<"id">> := <<"abc">>}}, + routing_trie:match(get, <<"/users/abc">>, T1)). + +%%-------------------------------------------------------------------- +%% Method filtering via find/3 (host = '_') +%%-------------------------------------------------------------------- + +find_with_method_test() -> + T0 = routing_trie:new(), + {ok, T1} = routing_trie:insert(post, <<"/users">>, T0, #{}), + + ?assertMatch({ok, _}, + routing_trie:find(post, <<"/users">>, T1)), + ?assertEqual(error, + routing_trie:find(get, <<"/users">>, T1)). + +%%-------------------------------------------------------------------- +%% Host-specific route only – no '_' fallback +%%-------------------------------------------------------------------- + +host_specific_only_test() -> + T0 = routing_trie:new(), + Host = <<"http://api.example.com">>, + + {ok, T1} = routing_trie:insert(get, Host, <<"/users">>, T0, #{}), + + ?assertMatch({ok, #{}}, + routing_trie:match(get, Host, <<"/users">>, T1)), + ?assertEqual(error, + routing_trie:match(get, + <<"http://other.example.com">>, + <<"/users">>, T1)). + +%%-------------------------------------------------------------------- +%% Host fallback to catch-all '_' when specific host is missing +%%-------------------------------------------------------------------- + +host_fallback_to_catchall_test() -> + T0 = routing_trie:new(), + %% insert only on '_' host + {ok, T1} = routing_trie:insert(get, '_', <<"/users">>, T0, #{}), + + %% should match when querying with another host due to fallback + ?assertMatch({ok, #{}}, + routing_trie:match(get, + <<"http://api.example.com">>, + <<"/users">>, T1)). + +%%-------------------------------------------------------------------- +%% find/4: host + method aware +%%-------------------------------------------------------------------- + +host_and_method_find_test() -> + T0 = routing_trie:new(), + Host = <<"http://api.example.com">>, + + {ok, T1} = routing_trie:insert(post, Host, <<"/users">>, T0, #{}), + + ?assertMatch({ok, _}, + routing_trie:find(post, Host, <<"/users">>, T1)), + ?assertEqual(error, + routing_trie:find(get, Host, <<"/users">>, T1)), + %% and also check that another host falls back to '_' only if '_' exists + ?assertEqual(error, + routing_trie:find(post, + <<"http://other.example.com">>, + <<"/users">>, T1)). + +%%-------------------------------------------------------------------- +%% Strict conflict detection for duplicate pattern+method +%%-------------------------------------------------------------------- + +strict_conflict_duplicate_pattern_test() -> + T0 = routing_trie:new(), + {ok, T1} = + routing_trie:insert(get, <<"/users/:id">>, T0, #{strict => true}), + + {error, conflict, Conf} = + routing_trie:insert(get, <<"/users/:id">>, T1, #{strict => true}), + + ?assertEqual(duplicate_pattern, maps:get(reason, Conf)), + ?assertEqual(get, maps:get(method, Conf)). + +%%-------------------------------------------------------------------- +%% to_list/1 sanity check +%%-------------------------------------------------------------------- + +to_list_simple_test() -> + T0 = routing_trie:new(), + {ok, T1} = routing_trie:insert(get, <<"/users/:id">>, T0, #{}), + Lines = routing_trie:to_list(T1), + + %% We expect "GET /users/:id" in the list + ?assert(lists:member(<<"GET /users/:id">>, Lines)). + +-endif. From 6ecbf78f46f2b6986d91e62f2aa306176b410279 Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Fri, 16 Jan 2026 20:12:13 +0100 Subject: [PATCH 08/21] Add tests --- src/nova_router.erl | 23 +- src/routing_trie.erl | 722 ------------------------------------------- 2 files changed, 13 insertions(+), 732 deletions(-) delete mode 100644 src/routing_trie.erl diff --git a/src/nova_router.erl b/src/nova_router.erl index 945bb96..1540927 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -34,7 +34,6 @@ add_routes/2 ]). --include_lib("routing_tree/include/routing_tree.hrl"). -include_lib("kernel/include/logger.hrl"). -include("../include/nova_router.hrl"). -include("../include/nova.hrl"). @@ -57,17 +56,19 @@ compiled_apps() -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), StorageBackend:get(?NOVA_APPS, []). + +%% TODO! We need to implement a way to get and remove plugins for a path plugins() -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), StorageBackend:get(?NOVA_PLUGINS, []). --spec compile(Apps :: [atom() | {atom(), map()}]) -> host_tree(). +-spec compile(Apps :: [atom() | {atom(), map()}]) -> nova_routing_trie:trie(). compile(Apps) -> UseStrict = application:get_env(nova, use_strict_routing, false), StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), StoredDispatch = StorageBackend:get(nova_dispatch, - routing_trie:new(#{strict => UseStrict})), + nova_routing_trie:new(#{options => #{strict => UseStrict}})), Dispatch = compile(Apps, StoredDispatch, #{}), %% Write the updated dispatch to storage StorageBackend:put(nova_dispatch, Dispatch), @@ -79,7 +80,7 @@ compile(Apps) -> execute(Req = #{host := Host, path := Path, method := Method}, Env) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), Dispatch = StorageBackend:get(nova_dispatch), - case routing_trie:find(Host, Path, Method, Dispatch) of + case nova_routing_trie:find(Host, Path, Method, Dispatch) of {error, not_found} -> logger:debug("Path ~p not found for ~p in ~p", [Path, Method, Host]), render_status_page('_', 404, #{error => "Not found in path"}, Req, Env); @@ -126,7 +127,7 @@ execute(Req = #{host := Host, path := Path, method := Method}, Env) -> } }; Error -> - ?LOG_ERROR(#{reason => <<"Unexpected return from routing_tree:lookup/4">>, + ?LOG_ERROR(#{reason => <<"Unexpected return from nova_routing_trie:lookup/4">>, return_object => Error}), render_status_page(Host, 404, #{error => Error}, Req, Env) end. @@ -143,7 +144,7 @@ lookup_url(Host, Path, Method) -> lookup_url(Host, Path, Method, Dispatch). lookup_url(Host, Path, Method, Dispatch) -> - routing_tree:lookup(Host, Path, Method, Dispatch). + nova_routing_trie:lookup(Host, Path, Method, Dispatch). %%-------------------------------------------------------------------- @@ -224,7 +225,7 @@ apply_callback(Module, Function, Args) -> [] end. --spec compile(Apps :: [atom() | {atom(), map()}], Dispatch :: host_tree(), Options :: map()) -> host_tree(). +-spec compile(Apps :: [atom() | {atom(), map()}], Dispatch :: nova_routing_trie:trie(), Options :: map()) -> nova_routing_trie:trie(). compile([], Dispatch, _Options) -> Dispatch; compile([{App, Options}|Tl], Dispatch, GlobalOptions) -> compile([App|Tl], Dispatch, maps:merge(Options, GlobalOptions)); @@ -432,7 +433,7 @@ render_status_page(Host, StatusCode, Data, Req, Env) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), Dispatch = StorageBackend:get(nova_dispatch), {Req0, Env0} = - case routing_tree:lookup(Host, StatusCode, '_', Dispatch) of + case nova_routing_trie:find(Host, StatusCode, '_', Dispatch) of {error, _} -> %% Render nova page if exists - We need to determine where to find this path? {Req, Env#{app => nova, @@ -456,7 +457,7 @@ render_status_page(Host, StatusCode, Data, Req, Env) -> insert(Host, Path, Combinator, Value, Tree) -> - try routing_tree:insert(Host, Path, Combinator, Value, Tree) of + try nova_routing_trie:insert(Host, Path, Combinator, Value, Tree) of Tree0 -> Tree0 catch throw:Exception -> @@ -522,6 +523,8 @@ routes(_) -> -compile(export_all). %% Export all functions for testing purpose -include_lib("eunit/include/eunit.hrl"). - +compile_empty_test() -> + Dispatch = compile([]), + ?assertEqual(nova_routing_trie:new(#{options => #{strict => false}}), Dispatch). -endif. diff --git a/src/routing_trie.erl b/src/routing_trie.erl deleted file mode 100644 index 2841037..0000000 --- a/src/routing_trie.erl +++ /dev/null @@ -1,722 +0,0 @@ --module(routing_trie). - --export([ - new/0, - new/1, - - %% Exports that does not use methods or host (Defaults to 'all' and '_') - insert/2, - insert/3, - member/2, - match/2, - find/2, - - %% Method-aware APIs (host defaults to '_') - insert/4, - member/3, - match/3, - find/3, - - %% Host + Method-aware APIs - insert/5, %% Method, Host, Path, Trie, Opts - member/4, %% Method, Host, Path, Trie - match/4, %% Method, Host, Path, Trie - find/4, %% Method, Host, Path, Trie - - to_list/1 - ]). - -%% Host-aware trie: -%% - 'trie()' is the host root -%% - each host key maps to a per-host trie_node() (routing tree) --opaque trie() :: #{ - options := map(), %% global/root options - hosts := #{ host_key() => trie_node() } - }. - --opaque trie_node() :: #{ - options := map(), %% options for this node - children := #{ child_key() => trie_node() }, - terminal_methods := methods_set() %% set of methods for which this node is terminal - }. - --type child_key() :: binary() | {wild, binary()}. - --type host_key() :: '_' | binary(). --type host_in() :: '_' | binary() | list() | atom(). - --type method() :: get | post | put | delete | patch | options | all. --type methods_set() :: #{ method() => true }. --type method_in() :: method() | binary() | list() | atom(). - --type conflict() :: #{reason := atom(), - at := [child_key()], - existing := child_key() | undefined, - incoming := child_key() | undefined, - conflicts_with := binary(), - incoming_path := binary(), - method := method(), - existing_methods := [method()]}. - --export_type([trie/0, trie_node/0, method/0]). - -%%-------------------------------------------------------------------- -%% API -%%-------------------------------------------------------------------- - -%%-------------------------------------------------------------------- -%% @doc -%% Creates a new empty host-root trie. -%% @end -%%-------------------------------------------------------------------- --spec new() -> trie(). -new() -> - new(#{}). - -%%-------------------------------------------------------------------- -%% @doc -%% Creates a new empty host-root trie with options. -%% Options: -%% - any key/value pairs to be stored in the root options map -%% @end -%%-------------------------------------------------------------------- --spec new(map()) -> trie(). -new(Opts) when is_map(Opts) -> - #{options => Opts, hosts => #{}}. - -%% Internal: create a new routing node (per-host trie root or child) --spec new_node() -> trie_node(). -new_node() -> - new_node(#{}). - --spec new_node(map()) -> trie_node(). -new_node(Opts) when is_map(Opts) -> - #{children => #{}, terminal_methods => #{}, options => Opts}. - -%%-------------------------------------------------------------------- -%% Host helpers -%%-------------------------------------------------------------------- - --spec norm_host(host_in()) -> host_key(). -norm_host('_') -> - '_'; -norm_host(Host) when is_binary(Host) -> - Host; -norm_host(Host) when is_list(Host) -> - list_to_binary(Host); -norm_host(Host) when is_atom(Host) -> - list_to_binary(atom_to_list(Host)). - --spec get_host_trie(host_key(), trie()) -> trie_node() | undefined. -get_host_trie(Host, Trie) -> - Hosts = maps:get(hosts, Trie, #{}), - maps:get(Host, Hosts, undefined). - - -%%-------------------------------------------------------------------- -%% @doc -%% Inserts a path into the trie. Same as calling -%% `insert(all, '_', Path, Trie, #{})` -%% @end -%%-------------------------------------------------------------------- --spec insert(iodata(), trie()) -> - {ok, trie()} | {error, conflict, conflict()}. -insert(Path, Trie) -> - insert(all, '_', Path, Trie, #{}). - -%%-------------------------------------------------------------------- -%% @doc -%% Inserts a path into the trie with options. -%% Same as calling `insert(all, '_', Path, Trie, Opts)`. -%% Options: -%% - strict: boolean() (default: false) -%% If true, performs a "safe" insert that checks for conflicts -%% (wildcard name conflicts, duplicate patterns, etc). -%% - overwrite: boolean() (default: false) -%% If true, will overwrite existing method at the given path if exists. -%% @end -%%-------------------------------------------------------------------- --spec insert(iodata(), trie(), map()) -> - {ok, trie()} | {error, conflict, conflict()}. -insert(Path, Trie, Opts) -> - insert(all, '_', Path, Trie, Opts). - -%%-------------------------------------------------------------------- -%% @doc -%% Inserts a method+path into the trie with options, using catch-all host '_'. -%% If a node already exists for the given path and method does not exist -%% already, the node is added. Otherwise it will return an error with -%% information about the conflict if not `overwrite` option is set to true. -%% @end -%%-------------------------------------------------------------------- --spec insert(method_in(), iodata(), trie(), map()) -> - {ok, trie()} | {error, conflict, conflict()}. -insert(Method, Path, Trie, Opts) -> - insert(Method, '_', Path, Trie, Opts). - -%%-------------------------------------------------------------------- -%% @doc -%% Inserts a method+host+path into the trie with options. -%% Host: -%% - Concrete host (binary/list/atom) => that host only -%% - '_' (atom) => catch-all host, used as fallback -%% Options: -%% - strict: boolean() (default: false) -%% - overwrite: boolean() (default: false) -%% @end -%%-------------------------------------------------------------------- --spec insert(method_in(), host_in(), iodata(), trie(), map()) -> - {ok, trie()} | {error, conflict, conflict()}. -insert(Method, HostIn, Path, Trie0 = #{options := RootOpts, hosts := Hosts}, Opts) -> - %% Merge options for this - RootOpts1 = maps:merge(RootOpts, Opts), - Trie1 = Trie0#{options := RootOpts1}, - - HostTrie = - case maps:get(HostIn, Hosts, undefined) of - undefined -> - %% Just create a new host trie with opts - new_node(Opts); - HostTrie0 -> - HostTrie0 - end, - case insert_inner(Method, Path, HostTrie, RootOpts1) of - {ok, HostTrie1} -> - Hosts0 = Hosts#{HostIn => HostTrie1}, - {ok, Trie1#{hosts => Hosts0}}; - {error, conflict, Conf} -> - {error, conflict, Conf}; - HostTrie1 -> - Hosts0 = Hosts#{HostIn => HostTrie1}, - {ok, Trie1#{hosts => Hosts0}} - end. - -%% Internal: insert into a single host's routing trie --spec insert_inner(method_in(), iodata(), trie_node(), map()) -> - {ok, trie_node()} | {error, conflict, conflict()}. -insert_inner(Method, Path, TrieNode, Opts) -> - Opts0 = maps:get(options, TrieNode, #{}), - Opts1 = maps:merge(Opts0, Opts), - Trie1 = TrieNode#{options := Opts1}, - - M = norm_method(Method), - Segs = segs_for_insert(Path), - - case maps:get(strict, Opts1, false) of - true -> - %% Call safe insert which checks for conflicts - safe_insert_segs(M, Segs, Trie1, [], Segs); - _ -> - insert_segs(M, Segs, Trie1) - end. - -%%-------------------------------------------------------------------- -%% @doc -%% Checks if a path exists in the trie. -%% Same as calling `member(all, Path, Trie)` (host '_'). -%% @end -%%-------------------------------------------------------------------- --spec member(iodata(), trie()) -> boolean(). -member(Path, Trie) -> - member(all, Path, Trie). - -%%-------------------------------------------------------------------- -%% @doc -%% Checks if a method+path exists in the trie (host '_'). -%% @end -%%-------------------------------------------------------------------- --spec member(method_in(), iodata(), trie()) -> boolean(). -member(Method0, Path, Trie) -> - member(Method0, '_', Path, Trie). - -%%-------------------------------------------------------------------- -%% @doc -%% Checks if a host+method+path exists in the trie. -%% Host: -%% - Concrete host => only that host -%% - '_' => catch-all host -%% @end -%%-------------------------------------------------------------------- --spec member(method_in(), host_in(), iodata(), trie()) -> boolean(). -member(Method0, HostIn, Path, Trie) -> - case find(Method0, HostIn, Path, Trie) of - {ok, _Node} -> true; - error -> false - end. - -%%-------------------------------------------------------------------- -%% @doc -%% Matches a concrete path against the trie, returning bindings if -%% matched. Same as calling `match(all, Path, Trie)` (host '_'). -%% @end -%%-------------------------------------------------------------------- --spec match(iodata(), trie()) -> - {ok, #{binary() => binary()}} | error. -match(Path, Trie) -> - match(all, Path, Trie). - -%%-------------------------------------------------------------------- -%% @doc -%% Matches a concrete method+path against the trie (host '_'), -%% returning bindings if matched. -%% @end -%%-------------------------------------------------------------------- --spec match(method_in(), iodata(), trie()) -> - {ok, #{binary() => binary()}} | error. -match(Method0, Path, Trie) -> - match(Method0, '_', Path, Trie). - -%%-------------------------------------------------------------------- -%% @doc -%% Matches a concrete host+method+path against the trie, returning bindings -%% if matched. -%% -%% Host: -%% - Concrete host => tries that host first, falls back to '_' if not found -%% - '_' => only routes stored under '_' host -%% @end -%%-------------------------------------------------------------------- --spec match(method_in(), host_in(), iodata(), trie()) -> - {ok, #{binary() => binary()}} | error. -match(Method0, HostIn, Path, Trie) -> - Host = norm_host(HostIn), - HostTrie = - case get_host_trie(Host, Trie) of - undefined when Host =/= '_' -> - get_host_trie('_', Trie); - T -> - T - end, - case HostTrie of - undefined -> - error; - L -> - M = norm_method(Method0), - Segs = segs_for_match(Path), - case do_match(Segs, L, #{}) of - {ok, Node, Binds} -> - case method_member(M, Node) of - true -> {ok, Binds}; - _ -> error - end; - _ -> - error - end - end. - -%%-------------------------------------------------------------------- -%% @doc -%% Finds the node for a given path, regardless of method (host '_'). -%% @end -%%-------------------------------------------------------------------- --spec find(iodata(), trie()) -> {ok, trie_node()} | error. -find(Path, Trie) -> - find(all, '_', Path, Trie). - -%%-------------------------------------------------------------------- -%% @doc -%% Finds the node for a given method+path (host '_'). -%% Returns {ok, Node} only if Method exists at that node. -%% @end -%%-------------------------------------------------------------------- --spec find(method_in(), iodata(), trie()) -> {ok, trie_node()} | error. -find(Method0, Path, Trie) -> - find(Method0, '_', Path, Trie). - -%%-------------------------------------------------------------------- -%% @doc -%% Finds the node for a given method+host+path. -%% Host resolution: -%% - Exact host map -%% - else, if Host =/= '_' and '_' exists, fall back to '_' -%% - else error -%% @end -%%-------------------------------------------------------------------- --spec find(method_in(), host_in(), iodata(), trie()) -> {ok, trie_node()} | error. -find(Method0, HostIn, Path, Trie) -> - Host = norm_host(HostIn), - HostTrie = - case get_host_trie(Host, Trie) of - undefined when Host =/= '_' -> - get_host_trie('_', Trie); - T -> - T - end, - case HostTrie of - undefined -> - error; - L -> - Segs = segs_for_insert(Path), - case descend_pattern(Segs, L) of - undefined -> - error; - Node -> - M = norm_method(Method0), - case method_member(M, Node) of - true -> {ok, Node}; - false -> error - end - end - end. - -%%-------------------------------------------------------------------- -%% @doc -%% Returns a list of all method+path combinations in the trie -%% (ignores host in the output; host dimension is flattened). -%% @end -%%-------------------------------------------------------------------- --spec to_list(trie()) -> [binary()]. -to_list(Trie) -> - Hosts = maps:get(hosts, Trie, #{}), - maps:fold( - fun(_Host, HostTrie, Acc) -> - gather(HostTrie, [], Acc) - end, [], Hosts). - -%%-------------------------------------------------------------------- -%% Internal functions -%%-------------------------------------------------------------------- -norm_method(M) when is_atom(M) -> - norm_method(atom_to_list(M)); -norm_method(M) when is_list(M) -> - norm_method(list_to_binary(string:uppercase(M))); -norm_method(<>) -> - case M of - <<"GET">> -> get; - <<"POST">> -> post; - <<"PUT">> -> put; - <<"DELETE">> -> delete; - <<"PATCH">> -> patch; - <<"OPTIONS">> -> options; - _ -> all - end. - -segs_for_insert(Path) when is_list(Path) -> - segs_for_insert(list_to_binary(Path)); -segs_for_insert(Path) when is_binary(Path) -> - Parts = [S || S <- binary:split(Path, <<"/">>, [global, trim]), S =/= <<>>], - [<<"/">> | [ to_key(S) || S <- Parts ]]. - -segs_for_match(Path) when is_list(Path) -> - segs_for_match(list_to_binary(Path)); -segs_for_match(Path) when is_binary(Path) -> - Parts = [S || S <- binary:split(Path, <<"/">>, [global, trim]), S =/= <<>>], - [<<"/">> | Parts]. - -to_key(<<":", Rest/binary>>) -> {wild, Rest}; -to_key(Bin) -> Bin. - -render_key({wild, Name}) -> <<":", Name/binary>>; -render_key(Bin) -> Bin. - -render_path(Segs) -> - case Segs of - [] -> <<"/">>; - [<<"/">>] -> <<"/">>; - [<<"/">> | Rest] -> - Seg = iolist_to_binary(lists:join(<<"/">>, [render_key(S) || S <- Rest])), - <<"/", (Seg)/binary>>; - _ -> - iolist_to_binary(lists:join(<<"/">>, [render_key(S) || S <- Segs])) - end. - -render_method(M) -> - case M of - get -> <<"GET">>; - post -> <<"POST">>; - put -> <<"PUT">>; - delete -> <<"DELETE">>; - patch -> <<"PATCH">>; - options -> <<"OPTIONS">>; - all -> <<"ALL">> - end. - -terminal_add(M, Node0=#{terminal_methods := Ms0}) -> - Node0#{terminal_methods := Ms0#{ M => true }}. - -method_member(M, #{terminal_methods := Ms}) -> - case M of - all -> maps:is_key(all, Ms) orelse (maps:size(Ms) > 0); - _ -> maps:is_key(M, Ms) orelse maps:is_key(all, Ms) - end. - -methods_list(#{terminal_methods := Ms}) -> - [K || {K, true} <- maps:to_list(Ms)]. - -%% Insert without conflict checking (single-host trie) -insert_segs(M, [], N0) -> - terminal_add(M, N0); -insert_segs(M, [K | Rest], N0) -> - Cs0 = maps:get(children, N0), - Child0 = maps:get(K, Cs0, new_node()), - Child1 = insert_segs(M, Rest, Child0), - N0#{children := maps:put(K, Child1, Cs0)}. - -descend_pattern([], N) -> - N; -descend_pattern([K | Rest], N) -> - Cs = maps:get(children, N), - case maps:find(K, Cs) of - error -> undefined; - {ok, Child} -> descend_pattern(Rest, Child) - end. - -%% Safe insert with conflict checking (single-host trie) -safe_insert_segs(M, [], N0, Prefix, Full) -> - Ms = methods_list(N0), - case lists:member(M, Ms) of - true -> - {error, conflict, #{ - reason => duplicate_pattern, - at => Prefix, - existing => undefined, - incoming => undefined, - conflicts_with => render_path(Prefix), - incoming_path => render_path(Full), - method => M, - existing_methods => Ms - }}; - _ -> - case lists:member(all, Ms) of - true when M =/= all -> - {error, conflict, #{ - reason => duplicate_due_to_all, - at => Prefix, - existing => undefined, - incoming => undefined, - conflicts_with => render_path(Prefix), - incoming_path => render_path(Full), - method => M, - existing_methods => Ms - }}; - _ when M =:= all, Ms =/= [] -> - {error, conflict, #{ - reason => duplicate_due_to_existing_methods, - at => Prefix, - existing => undefined, - incoming => undefined, - conflicts_with => render_path(Prefix), - incoming_path => render_path(Full), - method => all, - existing_methods => Ms - }}; - _ -> - {ok, terminal_add(M, N0)} - end - end; - -safe_insert_segs(M, [K = {wild, NewVar} | Rest], N0, Prefix, Full) -> - Cs0 = maps:get(children, N0), - ExistingWild = find_wild_child(Cs0), - case ExistingWild of - none -> - Child0 = new_node(), - case safe_insert_segs(M, Rest, Child0, Prefix ++ [K], Full) of - {ok, Child1} -> - {ok, N0#{children := maps:put(K, Child1, Cs0)}}; - {error, conflict, Info} -> - {error, conflict, Info} - end; - {wild, ExistingVar, ChildN} -> - if ExistingVar =:= NewVar -> - case safe_insert_segs(M, Rest, ChildN, Prefix ++ [K], Full) of - {ok, Child1} -> - {ok, N0#{children := maps:put({wild, ExistingVar}, Child1, Cs0)}}; - {error, conflict, Info} -> - {error, conflict, Info} - end; - true -> - {error, conflict, #{ - reason => wildcard_name_conflict, - at => Prefix, - existing => {wild, ExistingVar}, - incoming => {wild, NewVar}, - conflicts_with => render_path(Prefix ++ [{wild, ExistingVar}]), - incoming_path => render_path(Prefix ++ [K] ++ Rest), - method => M, - existing_methods => methods_list(ChildN) - }} - end - end; - -safe_insert_segs(M, [K | Rest], N0, Prefix, Full) -> - Cs0 = maps:get(children, N0), - Child0 = maps:get(K, Cs0, new_node()), - case safe_insert_segs(M, Rest, Child0, Prefix ++ [K], Full) of - {ok, Child1} -> - {ok, N0#{children := maps:put(K, Child1, Cs0)}}; - {error, conflict, Info} -> - {error, conflict, Info} - end. - -find_wild_child(Cs) -> - maps:fold( - fun - ({wild, Var}, Child, none) -> {wild, Var, Child}; - (_K, _Child, Acc) -> Acc - end, none, Cs). - -do_match([], N, Binds) -> - {ok, N, Binds}; -do_match([Seg | Rest], N, Binds0) -> - Cs = maps:get(children, N), - - %% 1) Exact first - Exact = case maps:find(Seg, Cs) of - {ok, C} -> - case do_match(Rest, C, Binds0) of - error -> error; - Ok -> Ok - end; - error -> error - end, - case Exact of - {ok, _, _} -> Exact; - error -> - %% 2) Wildcard fallback - case find_wild_child(Cs) of - none -> error; - {wild, VarName, C0} -> - do_match(Rest, C0, Binds0#{ VarName => Seg }) - end - end. - -gather(N, AccSegs, AccOut) -> - Ms = methods_list(N), - AccOut1 = - case Ms of - [] -> AccOut; - _ -> - Path = render_path(AccSegs), - MethodLines = - [<< (render_method(M))/binary, " ", Path/binary >> || M <- Ms], - MethodLines ++ AccOut - end, - Cs = maps:get(children, N), - maps:fold( - fun(K, Child, Out) -> - gather(Child, AccSegs ++ [K], Out) - end, AccOut1, Cs). - - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -%%-------------------------------------------------------------------- -%% Basic insert / match on default host '_' -%%-------------------------------------------------------------------- - -default_host_insert_and_match_test() -> - T0 = routing_trie:new(), - {ok, T1} = routing_trie:insert(get, <<"/users">>, T0, #{}), - - ?assert(routing_trie:member(get, <<"/users">>, T1)), - ?assertMatch({ok, #{}}, - routing_trie:match(get, <<"/users">>, T1)). - -%%-------------------------------------------------------------------- -%% Wildcard path ("/users/:id") on default host -%%-------------------------------------------------------------------- - -wildcard_path_match_test() -> - T0 = routing_trie:new(), - {ok, T1} = routing_trie:insert(get, <<"/users/:id">>, T0, #{strict => true}), - - ?assertMatch({ok, #{<<"id">> := <<"42">>}}, - routing_trie:match(get, <<"/users/42">>, T1)), - ?assertMatch({ok, #{<<"id">> := <<"abc">>}}, - routing_trie:match(get, <<"/users/abc">>, T1)). - -%%-------------------------------------------------------------------- -%% Method filtering via find/3 (host = '_') -%%-------------------------------------------------------------------- - -find_with_method_test() -> - T0 = routing_trie:new(), - {ok, T1} = routing_trie:insert(post, <<"/users">>, T0, #{}), - - ?assertMatch({ok, _}, - routing_trie:find(post, <<"/users">>, T1)), - ?assertEqual(error, - routing_trie:find(get, <<"/users">>, T1)). - -%%-------------------------------------------------------------------- -%% Host-specific route only – no '_' fallback -%%-------------------------------------------------------------------- - -host_specific_only_test() -> - T0 = routing_trie:new(), - Host = <<"http://api.example.com">>, - - {ok, T1} = routing_trie:insert(get, Host, <<"/users">>, T0, #{}), - - ?assertMatch({ok, #{}}, - routing_trie:match(get, Host, <<"/users">>, T1)), - ?assertEqual(error, - routing_trie:match(get, - <<"http://other.example.com">>, - <<"/users">>, T1)). - -%%-------------------------------------------------------------------- -%% Host fallback to catch-all '_' when specific host is missing -%%-------------------------------------------------------------------- - -host_fallback_to_catchall_test() -> - T0 = routing_trie:new(), - %% insert only on '_' host - {ok, T1} = routing_trie:insert(get, '_', <<"/users">>, T0, #{}), - - %% should match when querying with another host due to fallback - ?assertMatch({ok, #{}}, - routing_trie:match(get, - <<"http://api.example.com">>, - <<"/users">>, T1)). - -%%-------------------------------------------------------------------- -%% find/4: host + method aware -%%-------------------------------------------------------------------- - -host_and_method_find_test() -> - T0 = routing_trie:new(), - Host = <<"http://api.example.com">>, - - {ok, T1} = routing_trie:insert(post, Host, <<"/users">>, T0, #{}), - - ?assertMatch({ok, _}, - routing_trie:find(post, Host, <<"/users">>, T1)), - ?assertEqual(error, - routing_trie:find(get, Host, <<"/users">>, T1)), - %% and also check that another host falls back to '_' only if '_' exists - ?assertEqual(error, - routing_trie:find(post, - <<"http://other.example.com">>, - <<"/users">>, T1)). - -%%-------------------------------------------------------------------- -%% Strict conflict detection for duplicate pattern+method -%%-------------------------------------------------------------------- - -strict_conflict_duplicate_pattern_test() -> - T0 = routing_trie:new(), - {ok, T1} = - routing_trie:insert(get, <<"/users/:id">>, T0, #{strict => true}), - - {error, conflict, Conf} = - routing_trie:insert(get, <<"/users/:id">>, T1, #{strict => true}), - - ?assertEqual(duplicate_pattern, maps:get(reason, Conf)), - ?assertEqual(get, maps:get(method, Conf)). - -%%-------------------------------------------------------------------- -%% to_list/1 sanity check -%%-------------------------------------------------------------------- - -to_list_simple_test() -> - T0 = routing_trie:new(), - {ok, T1} = routing_trie:insert(get, <<"/users/:id">>, T0, #{}), - Lines = routing_trie:to_list(T1), - - %% We expect "GET /users/:id" in the list - ?assert(lists:member(<<"GET /users/:id">>, Lines)). - --endif. From 1ddc0436e5847140260fa157311c9019fc36f254 Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Fri, 16 Jan 2026 21:25:03 +0100 Subject: [PATCH 09/21] Add routing trie with some tests --- rebar.config | 1 - src/nova_router.erl | 22 +- src/nova_routing_trie.erl | 984 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 1005 insertions(+), 2 deletions(-) create mode 100644 src/nova_routing_trie.erl diff --git a/rebar.config b/rebar.config index e7a6c92..90c308a 100644 --- a/rebar.config +++ b/rebar.config @@ -14,7 +14,6 @@ {cowboy, "2.13.0"}, {erlydtl, "0.14.0"}, {jhn_stdlib, "5.4.0"}, - {routing_tree, "1.0.11"}, {thoas, "1.2.1"} ]}. diff --git a/src/nova_router.erl b/src/nova_router.erl index 1540927..176175d 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -31,7 +31,8 @@ %% Modulates the routes-table add_routes/1, - add_routes/2 + add_routes/2, + remove_application/1 ]). -include_lib("kernel/include/logger.hrl"). @@ -201,6 +202,25 @@ add_routes(App, Routes) -> throw({error, {invalid_routes, App, Routes}}). +%%-------------------------------------------------------------------- +%% @doc +%% Remove all routes associated with the given application. +%% @end +%%-------------------------------------------------------------------- +-spec remove_application(Application :: atom()) -> ok. +remove_application(Application) when is_atom(Application) -> + Dispatch = persistent_term:get(nova_dispatch), + %% Remove all routes for this application + {ok, Dispatch0} = + nova_routing_trie:foldl(Dispatch, + fun(R) -> + [ X || X = {_Host, _Prefix, #nova_handler_value{app = App}} <- R, + App =/= Application ] + end), + persistent_term:put(nova_dispatch, Dispatch0), + ok. + + %%%%%%%%%%%%%%%%%%%%%%%% %% INTERNAL FUNCTIONS %% %%%%%%%%%%%%%%%%%%%%%%%% diff --git a/src/nova_routing_trie.erl b/src/nova_routing_trie.erl new file mode 100644 index 0000000..97c9267 --- /dev/null +++ b/src/nova_routing_trie.erl @@ -0,0 +1,984 @@ +-module(nova_routing_trie). + +-export([ + new/0, + new/1, + + %% Transform / rebuild from routes + foldl/2, + + %% Insert + insert/4, + insert/5, + insert/6, + + %% Membership + member/2, + member/3, + member/4, + + %% Unified lookup + lookup/2, + lookup/3, + lookup/4, + + to_list/1, + from_list/1 + ]). + +%% Host-aware trie: +%% - 'trie()' is the host root +%% - each host key maps to a per-host trie_node() (routing tree) +-opaque trie() :: #{ + options := map(), %% global/root options + hosts := #{ host_key() => trie_node() } + }. + +%% Per-host routing tree node +%% - children: nested segments +%% - terminal: map MethodBin => Payload +-opaque trie_node() :: #{ + options := map(), %% options for this node + children := #{ child_key() => trie_node() }, + plugins := #{ + pre_request := [module()], + post_request := [module()] + }, + terminal := #{ method() => payload() } + }. + +-type child_key() :: binary() | {wild, binary()}. + +-type host_key() :: '_' | binary(). +-type host_in() :: '_' | binary() | list() | atom(). + +%% Stored method representation: uppercase binary, e.g. <<"GET">>, <<"ALL">> +-type method() :: binary(). + +%% External method input: atom (get), binary (<<"GET">>), or string +-type method_in() :: method() | atom() | list(). + +-type payload() :: term(). + +%% A "route" is the canonical unit we can rebuild the trie from. +%% +%% Supported input shapes: +%% {Path, Method, Payload} +%% {Path, Method, Payload, Opts} +%% {Host, Path, Method, Payload} +%% {Host, Path, Method, Payload, Opts} +%% +%% Where: +%% - Host is optional (defaults to '_') +%% - Opts is optional (defaults to #{}) and is merged into trie options for that insertion +-type route() :: + {iodata(), method_in(), payload()} | + {iodata(), method_in(), payload(), map()} | + {host_in(), iodata(), method_in(), payload()} | + {host_in(), iodata(), method_in(), payload(), map()}. + +-type conflict() :: #{ + reason := atom(), + at := [child_key()], + existing := child_key() | undefined, + incoming := child_key() | undefined, + conflicts_with := binary(), + incoming_path := binary(), + method := method(), + existing_methods:= [method()] + }. + +-export_type([trie/0, trie_node/0, method/0]). + +%%==================================================================== +%% API +%%==================================================================== + +%%-------------------------------------------------------------------- +%% Constructors +%%-------------------------------------------------------------------- + +-spec new() -> trie(). +new() -> + new(#{}). + +-spec new(map()) -> trie(). +new(Opts) when is_map(Opts) -> + #{options => Opts, hosts => #{}}. + +%% Internal node constructor (per-host trees) +-spec new_node() -> trie_node(). +new_node() -> + new_node(#{}). + +-spec new_node(map()) -> trie_node(). +new_node(Opts) when is_map(Opts) -> + #{children => #{}, terminal => #{}, options => Opts, plugins => #{pre_request => [], + post_request => []}}. + +%%-------------------------------------------------------------------- +%% Host helpers +%%-------------------------------------------------------------------- + +-spec norm_host(host_in()) -> host_key(). +norm_host('_') -> + '_'; +norm_host(Host) when is_binary(Host) -> + Host; +norm_host(Host) when is_list(Host) -> + list_to_binary(Host); +norm_host(Host) when is_atom(Host) -> + list_to_binary(atom_to_list(Host)). + +%%-------------------------------------------------------------------- +%% Insert +%% +%% Canonical full form: +%% insert(Host, Path, Method, Payload, Trie, Opts) +%% +%% Convenience: +%% insert(Path, Method, Payload, Trie). % Host='_', Opts=#{} +%% insert(Host, Path, Method, Payload, Trie). % Opts=#{} +%%-------------------------------------------------------------------- + +-spec insert(iodata(), method_in(), payload(), trie()) -> + {ok, trie()} | {error, conflict, conflict()}. +insert(Path, Method, Payload, Trie) -> + insert('_', Path, Method, Payload, Trie, #{}). + +-spec insert(host_in(), iodata(), method_in(), payload(), trie()) -> + {ok, trie()} | {error, conflict, conflict()}. +insert(HostIn, Path, Method, Payload, Trie) -> + insert(HostIn, Path, Method, Payload, Trie, #{}). + +-spec insert(host_in(), iodata(), method_in(), payload(), trie(), map()) -> + {ok, trie()} | {error, conflict, conflict()}. +insert(HostIn, Path, Method, Payload, Trie0, Opts) -> + Host = norm_host(HostIn), + RootOpts0 = maps:get(options, Trie0, #{}), + RootOpts1 = maps:merge(RootOpts0, Opts), + Trie1 = Trie0#{options => RootOpts1}, + Hosts = maps:get(hosts, Trie1, #{}), + {Trie2, HostTrie0} = + case maps:get(Host, Hosts, undefined) of + undefined -> + HostTrie = new_node(RootOpts1), + Hosts1 = Hosts#{Host => HostTrie}, + {Trie1#{hosts := Hosts1}, HostTrie}; + HostTrie -> + {Trie1, HostTrie} + end, + case insert_inner(Method, Path, Payload, HostTrie0, RootOpts1) of + {ok, HostTrie1} -> + Hosts2 = maps:get(hosts, Trie2, #{}), + Hosts3 = Hosts2#{Host => HostTrie1}, + {ok, Trie2#{hosts := Hosts3}}; + {error, conflict, Conf} -> + {error, conflict, Conf} + end. + +%% Internal: insert into a single host's routing trie +-spec insert_inner(method_in(), iodata(), payload(), trie_node(), map()) -> + {ok, trie_node()} | {error, conflict, conflict()}. +insert_inner(Method, Path, Payload, TrieNode, Opts) -> + Opts0 = maps:get(options, TrieNode, #{}), + Opts1 = maps:merge(Opts0, Opts), + Trie1 = TrieNode#{options := Opts1}, + + M = norm_method(Method), + Segs = segs_for_insert(Path), + Strict = maps:get(strict, Opts1, false), + + case Strict of + true -> + %% Strict: conflicts become errors + safe_insert_segs(M, Payload, Segs, Trie1, [], Segs); + false -> + %% Non-strict: run safe_insert_segs only for detection, + %% then always perform regular insert_segs. Conflicts -> warnings. + case safe_insert_segs(M, Payload, Segs, Trie1, [], Segs) of + {error, conflict, Conf} -> + warn_conflict(Conf), + {ok, insert_segs(M, Payload, Segs, Trie1)}; + {ok, _TmpNode} -> + {ok, insert_segs(M, Payload, Segs, Trie1)} + end + end. + +%%-------------------------------------------------------------------- +%% Warnings (non-strict conflicts) +%%-------------------------------------------------------------------- +warn_conflict(Conf) -> + Reason = maps:get(reason, Conf), + Method = maps:get(method, Conf), + IncomingPath = maps:get(incoming_path, Conf), + ConflictsWith = maps:get(conflicts_with, Conf), + io:format( + "routing_trie warning (~p): ~s ~s conflicts with ~s~n", + [Reason, render_method(Method), IncomingPath, ConflictsWith] + ). + +%%-------------------------------------------------------------------- +%% Membership (uses lookup) +%%-------------------------------------------------------------------- + +-spec member(iodata(), trie()) -> boolean(). +member(Path, Trie) -> + member(<<"ALL">>, Path, Trie). + +-spec member(method_in(), iodata(), trie()) -> boolean(). +member(Method0, Path, Trie) -> + member(Method0, '_', Path, Trie). + +-spec member(method_in(), host_in(), iodata(), trie()) -> boolean(). +member(Method0, HostIn, Path, Trie) -> + case lookup(Method0, HostIn, Path, Trie) of + {ok, _Node, _Payload, _Binds} -> true; + error -> false + end. + +%%-------------------------------------------------------------------- +%% Unified lookup +%% +%% - Performs host selection (with '_' fallback) +%% - Performs path + wildcard matching +%% - Chooses payload according to Method (method-specific or <<"ALL">>) +%% - Returns: +%% error +%% | {ok, Node, Payload, Bindings} +%%-------------------------------------------------------------------- + +-spec lookup(iodata(), trie()) -> + {ok, trie_node(), term(), #{binary() => binary()}} | error. +lookup(Path, Trie) -> + lookup(<<"ALL">>, '_', Path, Trie). + +-spec lookup(method_in(), iodata(), trie()) -> + {ok, trie_node(), term(), #{binary() => binary()}} | error. +lookup(Method0, Path, Trie) -> + lookup(Method0, '_', Path, Trie). + +-spec lookup(method_in(), host_in(), iodata(), trie()) -> + {ok, trie_node(), term(), #{binary() => binary()}} | error. +lookup(Method0, HostIn, Path, Trie = #{hosts := Hosts}) -> + Host = norm_host(HostIn), + case maps:get(Host, Hosts, undefined) of + undefined when Host =/= '_' -> + lookup(Method0, '_', Path, Trie); + undefined -> + error; + HostTrie -> + M = norm_method(Method0), + Segs = segs_for_match(Path), + case do_match(Segs, HostTrie, #{}) of + {ok, Node, Binds} -> + case method_payload(M, Node) of + {ok, Payload} -> {ok, Node, Payload, Binds}; + error -> error + end; + _ -> + error + end + end. + + +%%-------------------------------------------------------------------- +%% to_list: flattened host view (ignores payloads) +%%-------------------------------------------------------------------- + +-spec to_list(trie()) -> [binary()]. +to_list(Trie) -> + Hosts = maps:get(hosts, Trie, #{}), + maps:fold( + fun(_Host, HostTrie, Acc) -> + gather(HostTrie, [], Acc) + end, [], Hosts). + +%%-------------------------------------------------------------------- +%% from_list: build a routing trie from a list of routes +%%-------------------------------------------------------------------- + +-spec from_list([route()]) -> {ok, trie()} | {error, conflict, conflict()}. +from_list(Routes) when is_list(Routes) -> + from_list(Routes, #{}). + +%%-------------------------------------------------------------------- +%% foldl: rebuild the trie by transforming its extracted routes +%% +%% Function :: fun(([route()]) -> [route()]) +%% +%% 1) Extracts routes from the trie (including payloads) +%% 2) Calls Function(Routes) +%% 3) Rebuilds a new trie from the returned routes +%%-------------------------------------------------------------------- + +-spec foldl(trie(), fun(([route()]) -> [route()])) -> + {ok, trie()} | {error, conflict, conflict()}. +foldl(Trie0, Fun) when is_map(Trie0), is_function(Fun, 1) -> + Routes0 = routes(Trie0), + Routes1 = Fun(Routes0), + case is_list(Routes1) of + true -> + from_list(Routes1, maps:get(options, Trie0, #{})); + false -> + erlang:error({badreturn, {foldl, Fun, Routes1}}) + end. + +%%==================================================================== +%% Internal functions (methods, segments, conflicts, matching) +%%==================================================================== + +%%-------------------------------------------------------------------- +%% from_list helpers +%%-------------------------------------------------------------------- + +-spec from_list([route()], map()) -> {ok, trie()} | {error, conflict, conflict()}. +from_list(Routes, RootOpts) when is_list(Routes), is_map(RootOpts) -> + lists:foldl( + fun + (Route, {ok, TrieAcc}) -> + insert_route(Route, TrieAcc); + (_Route, Err={error, conflict, _Conf}) -> + Err + end, + {ok, new(RootOpts)}, + Routes + ). + +-spec insert_route(route(), trie()) -> {ok, trie()} | {error, conflict, conflict()}. +insert_route({Path, Method, Payload}, Trie) -> + insert(Path, Method, Payload, Trie); +insert_route({Path, Method, Payload, Opts}, Trie) when is_map(Opts) -> + insert('_', Path, Method, Payload, Trie, Opts); +insert_route({Host, Path, Method, Payload}, Trie) -> + insert(Host, Path, Method, Payload, Trie); +insert_route({Host, Path, Method, Payload, Opts}, Trie) when is_map(Opts) -> + insert(Host, Path, Method, Payload, Trie, Opts); +insert_route(Other, _Trie) -> + erlang:error({bad_route, Other}). + +%%-------------------------------------------------------------------- +%% Route extraction (used by foldl/2) +%%-------------------------------------------------------------------- + +-spec routes(trie()) -> [route()]. +routes(Trie) -> + Hosts = maps:get(hosts, Trie, #{}), + maps:fold( + fun(Host, HostTrie, Acc) -> + gather_routes(HostTrie, [], Host, Acc) + end, + [], + Hosts + ). + +gather_routes(Node, AccSegs, Host, Acc0) -> + Term = maps:get(terminal, Node, #{}), + Acc1 = + maps:fold( + fun(M, Payload, A) -> + Path = render_path(AccSegs), + [{Host, Path, M, Payload} | A] + end, + Acc0, + Term + ), + Cs = maps:get(children, Node, #{}), + maps:fold( + fun(K, Child, A) -> + gather_routes(Child, AccSegs ++ [K], Host, A) + end, + Acc1, + Cs + ). + +%% Methods + +-spec norm_method(method_in()) -> method(). +norm_method(M) when is_binary(M) -> + %% Assume already normalized (e.g. <<"GET">>, <<"POST">>, <<"ALL">>) + M; +norm_method(M) when is_atom(M) -> + list_to_binary(string:uppercase(atom_to_list(M))); +norm_method(M) when is_list(M) -> + list_to_binary(string:uppercase(M)). + +terminal_add(M, Payload, Node0=#{terminal := Term0}) -> + Node0#{terminal := Term0#{ M => Payload }}. + +%% Resolve payload for a given method at a terminal node +-spec method_payload(method(), trie_node()) -> {ok, term()} | error. +method_payload(M, #{terminal := Term}) -> + case M of + <<"ALL">> -> + case maps:find(<<"ALL">>, Term) of + {ok, Payload} -> + {ok, Payload}; + error -> + case maps:to_list(Term) of + [] -> error; + [{_, P} | _] -> {ok, P} + end + end; + _ -> + case maps:find(M, Term) of + {ok, Payload} -> + {ok, Payload}; + error -> + case maps:find(<<"ALL">>, Term) of + {ok, Payload} -> {ok, Payload}; + error -> error + end + end + end. + +%% method_member(M, Node) -> +%% case method_payload(M, Node) of +%% {ok, _} -> true; +%% error -> false +%% end. + +methods_list(#{terminal := Term}) -> + [K || {K, _} <- maps:to_list(Term)]. + +render_method(M) when is_binary(M) -> + M; +render_method(M) -> + norm_method(M). + +%% Segments and paths + +segs_for_insert(Path) when is_list(Path) -> + segs_for_insert(list_to_binary(Path)); +segs_for_insert(Path) when is_binary(Path) -> + Parts = [S || S <- binary:split(Path, <<"/">>, [global, trim]), S =/= <<>>], + [<<"/">> | [ to_key(S) || S <- Parts ]]. + +segs_for_match(Path) when is_list(Path) -> + segs_for_match(list_to_binary(Path)); +segs_for_match(Path) when is_binary(Path) -> + Parts = [S || S <- binary:split(Path, <<"/">>, [global, trim]), S =/= <<>>], + [<<"/">> | Parts]. + +to_key(<<":", Rest/binary>>) -> {wild, Rest}; +to_key(Bin) -> Bin. + +render_key({wild, Name}) -> <<":", Name/binary>>; +render_key(Bin) -> Bin. + +%% Older-Erlang-safe binary join +-spec join_with_sep([binary()], binary()) -> binary(). +join_with_sep([], _Sep) -> + <<>>; +join_with_sep([First | Rest], Sep) -> + iolist_to_binary( + lists:foldl( + fun(Bin, Acc) -> [Acc, Sep, Bin] end, + First, + Rest + )). + +render_path(Segs) -> + case Segs of + [] -> <<"/">>; + [<<"/">>] -> <<"/">>; + [<<"/">> | Rest] -> + RestBins = [render_key(S) || S <- Rest], + <<"/", (join_with_sep(RestBins, <<"/">>))/binary>>; + _ -> + join_with_sep([render_key(S) || S <- Segs], <<"/">>) + end. + +%% Insert without conflict checking (single-host trie) + +insert_segs(M, Payload, [], N0) -> + terminal_add(M, Payload, N0); +insert_segs(M, Payload, [K | Rest], N0) -> + Cs0 = maps:get(children, N0), + Child0 = maps:get(K, Cs0, new_node()), + Child1 = insert_segs(M, Payload, Rest, Child0), + N0#{children := maps:put(K, Child1, Cs0)}. + +%% Conflict helpers + +%% Find any wildcard child +find_wild_child(Cs) -> + maps:fold( + fun + ({wild, Var}, Child, none) -> {wild, Var, Child}; + (_K, _Child, Acc) -> Acc + end, none, Cs). + +%% Find static child that will be affected by new wildcard at same depth +find_static_overshadow_child(Cs, M) -> + maps:fold( + fun + ({wild, _}, _Child, Acc) -> + Acc; + (K, Child, none) -> + Ms = methods_list(Child), + case overshadow_methods(M, Ms) of + true -> {found, {K, Child, Ms}}; + false -> none + end; + (_K, _Child, Acc) -> + Acc + end, none, Cs). + +%% Does method M conflict (overlap) with existing methods? +overshadow_methods(M, ExistingMs) -> + case M of + <<"ALL">> -> + ExistingMs =/= []; + _ -> + lists:member(M, ExistingMs) orelse + lists:member(<<"ALL">>, ExistingMs) + end. + +%% Safe insert with conflict checking (single-host trie) +safe_insert_segs(M, Payload, [], N0, Prefix, Full) -> + Ms = methods_list(N0), + case lists:member(M, Ms) of + true -> + {error, conflict, #{ + reason => duplicate_pattern, + at => Prefix, + existing => undefined, + incoming => undefined, + conflicts_with => render_path(Prefix), + incoming_path => render_path(Full), + method => M, + existing_methods=> Ms + }}; + false -> + case lists:member(<<"ALL">>, Ms) of + true when M =/= <<"ALL">> -> + {error, conflict, #{ + reason => duplicate_due_to_all, + at => Prefix, + existing => undefined, + incoming => undefined, + conflicts_with => render_path(Prefix), + incoming_path => render_path(Full), + method => M, + existing_methods=> Ms + }}; + _ when M =:= <<"ALL">>, Ms =/= [] -> + {error, conflict, #{ + reason => duplicate_due_to_existing_methods, + at => Prefix, + existing => undefined, + incoming => undefined, + conflicts_with => render_path(Prefix), + incoming_path => render_path(Full), + method => M, + existing_methods=> Ms + }}; + _ -> + {ok, terminal_add(M, Payload, N0)} + end + end; + +%% Branch when we insert a wildcard segment +safe_insert_segs(M, Payload, [K = {wild, NewVar} | Rest], N0, Prefix, Full) -> + Cs0 = maps:get(children, N0), + + %% 1) Wildcard overshadowing existing static terminal at same depth + case Rest of + [] -> + case find_static_overshadow_child(Cs0, M) of + {found, {ExistingKey, _ChildN, ExistingMs}} -> + {error, conflict, #{ + reason => overshadowing_route, + at => Prefix, + existing => ExistingKey, + incoming => K, + conflicts_with => render_path(Prefix ++ [ExistingKey]), + incoming_path => render_path(Full), + method => M, + existing_methods=> ExistingMs + }}; + none -> + wildcard_name_insert(M, Payload, NewVar, Rest, + N0, Prefix, Full, Cs0) + end; + _ -> + wildcard_name_insert(M, Payload, NewVar, Rest, + N0, Prefix, Full, Cs0) + end; + +%% Branch when we insert a static segment +safe_insert_segs(M, Payload, [K | Rest], N0, Prefix, Full) -> + Cs0 = maps:get(children, N0), + + %% Static overshadowing existing wildcard terminal at same depth + case Rest of + [] -> + case find_wild_child(Cs0) of + {wild, ExistingVar, ChildN} -> + ExistingMs = methods_list(ChildN), + case overshadow_methods(M, ExistingMs) of + true -> + {error, conflict, #{ + reason => overshadowing_route, + at => Prefix, + existing => {wild, ExistingVar}, + incoming => K, + conflicts_with => render_path(Prefix ++ [{wild, ExistingVar}]), + incoming_path => render_path(Full), + method => M, + existing_methods=> ExistingMs + }}; + false -> + static_continue(M, Payload, K, Rest, + N0, Prefix, Full, Cs0) + end; + none -> + static_continue(M, Payload, K, Rest, + N0, Prefix, Full, Cs0) + end; + _ -> + static_continue(M, Payload, K, Rest, + N0, Prefix, Full, Cs0) + end. + +%% Helper: continue wildcard insertion after overshadow/name checks +wildcard_name_insert(M, Payload, NewVar, Rest, N0, Prefix, Full, Cs0) -> + ExistingWild = find_wild_child(Cs0), + case ExistingWild of + none -> + Child0 = new_node(), + case safe_insert_segs(M, Payload, Rest, Child0, + Prefix ++ [{wild, NewVar}], Full) of + {ok, Child1} -> + {ok, N0#{children := maps:put({wild, NewVar}, Child1, Cs0)}}; + {error, conflict, Info} -> + {error, conflict, Info} + end; + {wild, ExistingVar, ChildN} -> + if ExistingVar =:= NewVar -> + case safe_insert_segs(M, Payload, Rest, ChildN, + Prefix ++ [{wild, NewVar}], Full) of + {ok, Child1} -> + {ok, N0#{children := maps:put({wild, ExistingVar}, Child1, Cs0)}}; + {error, conflict, Info} -> + {error, conflict, Info} + end; + true -> + {error, conflict, #{ + reason => wildcard_name_conflict, + at => Prefix, + existing => {wild, ExistingVar}, + incoming => {wild, NewVar}, + conflicts_with => render_path(Prefix ++ [{wild, ExistingVar}]), + incoming_path => render_path(Prefix ++ [{wild, NewVar}] ++ Rest), + method => M, + existing_methods=> methods_list(ChildN) + }} + end + end. + +%% Helper: continue static insertion after overshadow check +static_continue(M, Payload, K, Rest, N0, Prefix, Full, Cs0) -> + Child0 = maps:get(K, Cs0, new_node()), + case safe_insert_segs(M, Payload, Rest, Child0, + Prefix ++ [K], Full) of + {ok, Child1} -> + {ok, N0#{children := maps:put(K, Child1, Cs0)}}; + {error, conflict, Info} -> + {error, conflict, Info} + end. + +%%-------------------------------------------------------------------- +%% Matching (runtime lookup) +%%-------------------------------------------------------------------- + +do_match([], N, Binds) -> + {ok, N, Binds}; +do_match([Seg | Rest], N, Binds0) -> + Cs = maps:get(children, N), + + %% 1) Exact first + Exact = case maps:find(Seg, Cs) of + {ok, C} -> + case do_match(Rest, C, Binds0) of + error -> error; + Ok -> Ok + end; + error -> error + end, + case Exact of + {ok, _, _} -> Exact; + error -> + %% 2) Wildcard fallback + case find_wild_child(Cs) of + none -> error; + {wild, VarName, C0} -> + do_match(Rest, C0, Binds0#{ VarName => Seg }) + end + end. + +%%-------------------------------------------------------------------- +%% to_list helpers +%%-------------------------------------------------------------------- + +gather(N, AccSegs, AccOut) -> + Ms = methods_list(N), + AccOut1 = + case Ms of + [] -> AccOut; + _ -> + Path = render_path(AccSegs), + MethodLines = + [<< (render_method(M))/binary, " ", Path/binary >> || M <- Ms], + MethodLines ++ AccOut + end, + Cs = maps:get(children, N), + maps:fold( + fun(K, Child, Out) -> + gather(Child, AccSegs ++ [K], Out) + end, AccOut1, Cs). + +%%==================================================================== +%% EUnit tests +%%==================================================================== +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +default_host_insert_and_lookup_test() -> + T0 = new(), + {ok, T1} = insert(<<"/users">>, get, payload1, T0), + + ?assert(member(get, <<"/users">>, T1)), + ?assertMatch({ok, _Node, payload1, #{}}, + lookup(get, <<"/users">>, T1)). + +binary_method_insert_and_lookup_test() -> + T0 = new(), + {ok, T1} = insert(<<"/users">>, <<"GET">>, payload1, T0), + + %% lookup using binary method + ?assertMatch({ok, _Node, payload1, #{}}, + lookup(<<"GET">>, <<"/users">>, T1)), + %% lookup using atom method + ?assertMatch({ok, _Node, payload1, #{}}, + lookup(get, <<"/users">>, T1)). + +wildcard_path_lookup_test() -> + T0 = new(), + {ok, T1} = insert('_', <<"/users/:id">>, get, payload1, T0, + #{strict => true}), + + ?assertMatch({ok, _Node, payload1, #{<<"id">> := <<"42">>}}, + lookup(get, <<"/users/42">>, T1)), + ?assertMatch({ok, _Node, payload1, #{<<"id">> := <<"abc">>}}, + lookup(get, <<"/users/abc">>, T1)). + +method_filtering_lookup_test() -> + T0 = new(), + {ok, T1} = insert(<<"/users">>, post, payload_post, T0), + + ?assertMatch({ok, _Node, payload_post, #{}}, + lookup(post, <<"/users">>, T1)), + ?assertEqual(error, + lookup(get, <<"/users">>, T1)). + +host_specific_only_test() -> + T0 = new(), + Host = <<"http://api.example.com">>, + + {ok, T1} = insert(Host, <<"/users">>, get, payload_host, T0), + + ?assertMatch({ok, _Node, payload_host, #{}}, + lookup(get, Host, <<"/users">>, T1)), + ?assertEqual(error, + lookup(get, + <<"http://other.example.com">>, + <<"/users">>, T1)). + +host_fallback_to_catchall_test() -> + T0 = new(), + %% insert only on '_' host + {ok, T1} = insert('_', <<"/users">>, get, payload_all, T0, #{}), + + %% should match when querying with another host due to fallback + ?assertMatch({ok, _Node, payload_all, #{}}, + lookup(get, + <<"http://api.example.com">>, + <<"/users">>, T1)). + +host_and_method_lookup_test() -> + T0 = new(), + Host = <<"http://api.example.com">>, + + {ok, T1} = insert(Host, <<"/users">>, post, payload_post, T0), + + ?assertMatch({ok, _Node, payload_post, #{}}, + lookup(post, Host, <<"/users">>, T1)), + ?assertEqual(error, + lookup(get, Host, <<"/users">>, T1)), + %% and also check that another host falls back to '_' only if '_' exists + ?assertEqual(error, + lookup(post, + <<"http://other.example.com">>, + <<"/users">>, T1)). + +strict_conflict_duplicate_pattern_test() -> + T0 = new(), + {ok, T1} = + insert('_', <<"/users/:id">>, get, payload1, T0, + #{strict => true}), + + {error, conflict, Conf} = + insert('_', <<"/users/:id">>, get, payload2, T1, + #{strict => true}), + + ?assertEqual(duplicate_pattern, maps:get(reason, Conf)), + ?assertEqual(<<"GET">>, maps:get(method, Conf)). + +overshadow_strict_conflict_static_then_wild_test() -> + T0 = new(), + {ok, T1} = insert('_', <<"/user/my_user">>, get, payload_static, T0, + #{strict => true}), + + {error, conflict, Conf} = + insert('_', <<"/user/:user_id">>, get, payload_wild, T1, + #{strict => true}), + + ?assertEqual(overshadowing_route, maps:get(reason, Conf)), + ?assertEqual(<<"GET">>, maps:get(method, Conf)), + ?assertEqual(<<"/user/my_user">>, maps:get(conflicts_with, Conf)), + ?assertEqual(<<"/user/:user_id">>, maps:get(incoming_path, Conf)). + +overshadow_strict_conflict_wild_then_static_test() -> + T0 = new(), + {ok, T1} = insert('_', <<"/user/:user_id">>, get, payload_wild, T0, + #{strict => true}), + + {error, conflict, Conf} = + insert('_', <<"/user/my_user">>, get, payload_static, T1, + #{strict => true}), + + ?assertEqual(overshadowing_route, maps:get(reason, Conf)), + ?assertEqual(<<"GET">>, maps:get(method, Conf)), + ?assertEqual(<<"/user/:user_id">>, maps:get(conflicts_with, Conf)), + ?assertEqual(<<"/user/my_user">>, maps:get(incoming_path, Conf)). + +overshadow_non_strict_warning_static_then_wild_test() -> + T0 = new(), + {ok, T1} = insert(<<"/user/my_user">>, get, payload_static, T0), + + %% This should only warn, not error + {ok, T2} = insert(<<"/user/:user_id">>, get, payload_wild, T1), + + %% /user/my_user should still match the static route + ?assertMatch({ok, _Node, payload_static, #{}}, + lookup(get, <<"/user/my_user">>, T2)), + %% and /user/other should match the wildcard route + ?assertMatch({ok, _Node, payload_wild, + #{<<"user_id">> := <<"other">>}}, + lookup(get, <<"/user/other">>, T2)). + +overshadow_non_strict_warning_wild_then_static_test() -> + T0 = new(), + {ok, T1} = insert(<<"/user/:user_id">>, get, payload_wild, T0), + + %% This should only warn, not error + {ok, T2} = insert(<<"/user/my_user">>, get, payload_static, T1), + + %% /user/my_user should match the static route + ?assertMatch({ok, _Node, payload_static, #{}}, + lookup(get, <<"/user/my_user">>, T2)), + %% and /user/other should match the wildcard route + ?assertMatch({ok, _Node, payload_wild, + #{<<"user_id">> := <<"other">>}}, + lookup(get, <<"/user/other">>, T2)). + +to_list_simple_test() -> + T0 = new(), + {ok, T1} = insert(<<"/users/:id">>, get, payload1, T0), + Lines = to_list(T1), + + %% We expect "GET /users/:id" in the list + ?assert(lists:member(<<"GET /users/:id">>, Lines)). + +lookup_returns_node_and_bindings_test() -> + T0 = new(), + {ok, T1} = insert(<<"localhost">>, <<"/user/:id">>, get, payload1, T0, + #{strict => true}), + {ok, Node, Payload, Binds} = lookup(get, <<"localhost">>, <<"/user/42">>, T1), + ?assert(is_map(Node)), + ?assertEqual(payload1, Payload), + ?assertMatch(#{<<"id">> := <<"42">>}, Binds). + +foldl_can_filter_routes_test() -> + T0 = new(), + {ok, T1} = insert(<<"/a">>, get, payload_a, T0), + {ok, T2} = insert(<<"/b">>, get, payload_b, T1), + + {ok, T3} = + foldl( + T2, + fun(Routes0) -> + [R || R = {_Host, Path, _M, _P} <- Routes0, + Path =/= <<"/b">>] + end + ), + + ?assertMatch({ok, _Node, payload_a, #{}}, + lookup(get, <<"/a">>, T3)), + ?assertEqual(error, + lookup(get, <<"/b">>, T3)). + + +foldl_can_rewrite_payloads_test() -> + T0 = new(), + {ok, T1} = insert(<<"/a">>, get, payload_a, T0), + {ok, T2} = insert(<<"/b">>, get, payload_b, T1), + + %% Byt payload för /a men låt /b vara oförändrad + {ok, T3} = + foldl( + T2, + fun(Routes0) -> + [case R of + {Host, <<"/a">>, <<"GET">>, payload_a} -> + {Host, <<"/a">>, <<"GET">>, payload_a_v2}; + _ -> + R + end || R <- Routes0] + end + ), + + ?assertMatch({ok, _NodeA, payload_a_v2, #{}}, + lookup(get, <<"/a">>, T3)), + ?assertMatch({ok, _NodeB, payload_b, #{}}, + lookup(get, <<"/b">>, T3)). + +foldl_can_rewrite_methods_test() -> + T0 = new(), + {ok, T1} = insert(<<"/a">>, get, payload_a, T0), + + %% Flytta routen från GET till POST (vi skickar 'post' som atom för att + %% samtidigt testa att from_list/insert normaliserar method korrekt) + {ok, T2} = + foldl( + T1, + fun(Routes0) -> + [case R of + {Host, <<"/a">>, <<"GET">>, Payload} -> + {Host, <<"/a">>, post, Payload}; + _ -> + R + end || R <- Routes0] + end + ), + + %% GET ska inte längre matcha + ?assertEqual(error, + lookup(get, <<"/a">>, T2)), + %% POST ska matcha och ge samma payload + ?assertMatch({ok, _Node, payload_a, #{}}, + lookup(post, <<"/a">>, T2)). + +-endif. From 003b22e30eab527314f0cda534093da3fc78acbf Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Fri, 16 Jan 2026 21:32:44 +0100 Subject: [PATCH 10/21] Add labeler.yml --- .github/labeler.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/labeler.yml diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..f18582c --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,14 @@ +labels: + code-change: + - changed-files: + - any-glob-to-any-file: + - "src/**" + - "include/**" + - "priv/**" + - "**/*.erl" + - "**/*.hrl" + - "**/*.app.src" + - "**/*.app" + documentation: + - changed-files: + - '*.md' From 9955a44808cb3977fd44c7a0e772a4018e6ecf7c Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Sat, 11 Apr 2026 20:33:42 +0200 Subject: [PATCH 11/21] Add the ability to specify both plugin and security-strategies for sub-apps --- src/nova_router.erl | 44 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/nova_router.erl b/src/nova_router.erl index 176175d..16d16e1 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -288,19 +288,43 @@ compile_paths([], Dispatch, Options) -> {ok, Dispatch, Options}; compile_paths([RouteInfo|Tl], Dispatch, Options) -> App = maps:get(app, Options), RouterFile = maps:get(router_file, Options), - %% Fetch the global plugins - GlobalPlugins = application:get_env(nova, plugins, []), - Plugins = maps:get(plugins, RouteInfo, GlobalPlugins), + + %% Fetch the global plugins - we need to check Options first to see what plugin-strategy we should use for this route: + Plugins = + case maps:get(plugin_strategy, Options, local_first) of + local_first -> + LocalPlugins = maps:get(plugins, RouteInfo, []), + GlobalPlugins = application:get_env(nova, plugins, []), + %% We need to make sure that the plugins are in the right order, so we use ukeysort to remove duplicates and keep the order of the first occurrence + lists:ukeysort(1, LocalPlugins ++ GlobalPlugins); + global_first -> + LocalPlugins = maps:get(plugins, RouteInfo, []), + GlobalPlugins = application:get_env(nova, plugins, []), + %% We need to make sure that the plugins are in the right order, so we use ukeysort to remove duplicates and keep the order of the first occurrence + lists:ukeysort(1, GlobalPlugins ++ LocalPlugins); + local_only -> + maps:get(plugins, RouteInfo, []); + global_only -> + application:get_env(nova, plugins, []); + {override, PluginList} when is_list(PluginList) -> + PluginList + end, Secure = - case maps:get(secure, Options, maps:get(security, RouteInfo, false)) of + case maps:get(override_secure, Options, false) of false -> - false; - {SMod, SFun} -> - ?LOG_DEPRECATED("v0.9.24", "The {Mod,Fun} format have been deprecated for " - "the 'secure'-section of a route table. Use the new format for routes.", RouterFile), - fun SMod:SFun/1; - SCallback -> + case maps:get(secure, Options, maps:get(security, RouteInfo, false)) of + false -> + false; + {SMod, SFun} -> + ?LOG_DEPRECATED("v0.9.24", "The {Mod,Fun} format have been deprecated for " + "the 'secure'-section of a route table. Use the new format for routes.", RouterFile), + fun SMod:SFun/1; + SCallback when is_function(SCallback) -> + SCallback + end; + %% We override the secure value for this route (app level) with the value provided in options + SCallback when is_function(SCallback) -> SCallback end, From 2f71b8d7218e7e060abc1363450a16356d04f7c3 Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Sat, 11 Apr 2026 20:33:42 +0200 Subject: [PATCH 12/21] Add the ability to specify both plugin and security-strategies for sub-apps --- src/nova_basic_handler.erl | 18 +------------ src/nova_router.erl | 44 ++++++++++++++++++++++++------- src/nova_routing_trie.erl | 53 ++++++++++++++++++-------------------- 3 files changed, 60 insertions(+), 55 deletions(-) diff --git a/src/nova_basic_handler.erl b/src/nova_basic_handler.erl index 05669b2..8ba1307 100644 --- a/src/nova_basic_handler.erl +++ b/src/nova_basic_handler.erl @@ -289,7 +289,7 @@ handle_ws(ok, State) -> %%%=================================================================== handle_view(View, Variables, Options, Req) -> - {ok, HTML} = render_dtl(View, Variables, []), + {ok, HTML} = View:render(Variables, []), Headers = case maps:get(headers, Options, undefined) of undefined -> @@ -303,22 +303,6 @@ handle_view(View, Variables, Options, Req) -> 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 - ?LOG_ERROR(#{msg => <<"Nova could not render template">>, template => View, reason => Reason}), - throw({404, {template_not_found, View}}); - _ -> - View:render(Variables, Options) - end; - _ -> - View:render(Variables, Options) - end. - - get_view_name({Mod, _Opts}) -> get_view_name(Mod); get_view_name(Mod) when is_atom(Mod) -> StrName = get_view_name(erlang:atom_to_list(Mod)), diff --git a/src/nova_router.erl b/src/nova_router.erl index 176175d..16d16e1 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -288,19 +288,43 @@ compile_paths([], Dispatch, Options) -> {ok, Dispatch, Options}; compile_paths([RouteInfo|Tl], Dispatch, Options) -> App = maps:get(app, Options), RouterFile = maps:get(router_file, Options), - %% Fetch the global plugins - GlobalPlugins = application:get_env(nova, plugins, []), - Plugins = maps:get(plugins, RouteInfo, GlobalPlugins), + + %% Fetch the global plugins - we need to check Options first to see what plugin-strategy we should use for this route: + Plugins = + case maps:get(plugin_strategy, Options, local_first) of + local_first -> + LocalPlugins = maps:get(plugins, RouteInfo, []), + GlobalPlugins = application:get_env(nova, plugins, []), + %% We need to make sure that the plugins are in the right order, so we use ukeysort to remove duplicates and keep the order of the first occurrence + lists:ukeysort(1, LocalPlugins ++ GlobalPlugins); + global_first -> + LocalPlugins = maps:get(plugins, RouteInfo, []), + GlobalPlugins = application:get_env(nova, plugins, []), + %% We need to make sure that the plugins are in the right order, so we use ukeysort to remove duplicates and keep the order of the first occurrence + lists:ukeysort(1, GlobalPlugins ++ LocalPlugins); + local_only -> + maps:get(plugins, RouteInfo, []); + global_only -> + application:get_env(nova, plugins, []); + {override, PluginList} when is_list(PluginList) -> + PluginList + end, Secure = - case maps:get(secure, Options, maps:get(security, RouteInfo, false)) of + case maps:get(override_secure, Options, false) of false -> - false; - {SMod, SFun} -> - ?LOG_DEPRECATED("v0.9.24", "The {Mod,Fun} format have been deprecated for " - "the 'secure'-section of a route table. Use the new format for routes.", RouterFile), - fun SMod:SFun/1; - SCallback -> + case maps:get(secure, Options, maps:get(security, RouteInfo, false)) of + false -> + false; + {SMod, SFun} -> + ?LOG_DEPRECATED("v0.9.24", "The {Mod,Fun} format have been deprecated for " + "the 'secure'-section of a route table. Use the new format for routes.", RouterFile), + fun SMod:SFun/1; + SCallback when is_function(SCallback) -> + SCallback + end; + %% We override the secure value for this route (app level) with the value provided in options + SCallback when is_function(SCallback) -> SCallback end, diff --git a/src/nova_routing_trie.erl b/src/nova_routing_trie.erl index 97c9267..2c19e27 100644 --- a/src/nova_routing_trie.erl +++ b/src/nova_routing_trie.erl @@ -94,10 +94,6 @@ %% API %%==================================================================== -%%-------------------------------------------------------------------- -%% Constructors -%%-------------------------------------------------------------------- - -spec new() -> trie(). new() -> new(#{}). @@ -106,30 +102,6 @@ new() -> new(Opts) when is_map(Opts) -> #{options => Opts, hosts => #{}}. -%% Internal node constructor (per-host trees) --spec new_node() -> trie_node(). -new_node() -> - new_node(#{}). - --spec new_node(map()) -> trie_node(). -new_node(Opts) when is_map(Opts) -> - #{children => #{}, terminal => #{}, options => Opts, plugins => #{pre_request => [], - post_request => []}}. - -%%-------------------------------------------------------------------- -%% Host helpers -%%-------------------------------------------------------------------- - --spec norm_host(host_in()) -> host_key(). -norm_host('_') -> - '_'; -norm_host(Host) when is_binary(Host) -> - Host; -norm_host(Host) when is_list(Host) -> - list_to_binary(Host); -norm_host(Host) when is_atom(Host) -> - list_to_binary(atom_to_list(Host)). - %%-------------------------------------------------------------------- %% Insert %% @@ -372,6 +344,31 @@ routes(Trie) -> Hosts ). + + +%%==================================================================== +%% Internal functions +%%==================================================================== +-spec new_node() -> trie_node(). +new_node() -> + new_node(#{}). + +-spec new_node(map()) -> trie_node(). +new_node(Opts) when is_map(Opts) -> + #{children => #{}, terminal => #{}, options => Opts, plugins => #{pre_request => [], + post_request => []}}. + +-spec norm_host(host_in()) -> host_key(). +norm_host('_') -> + '_'; +norm_host(Host) when is_binary(Host) -> + Host; +norm_host(Host) when is_list(Host) -> + list_to_binary(Host); +norm_host(Host) when is_atom(Host) -> + list_to_binary(atom_to_list(Host)). + + gather_routes(Node, AccSegs, Host, Acc0) -> Term = maps:get(terminal, Node, #{}), Acc1 = From 0435816d44588c330ade8743c74603dbc8fc2894 Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Tue, 19 May 2026 09:11:00 +0200 Subject: [PATCH 13/21] Jailed pathing and some improvements in configuration --- guides/configuration.md | 2 +- include/nova_router.hrl | 2 +- src/nova.erl | 1 + src/nova_router.erl | 71 +++++++++++++++++++++++++-------------- src/nova_routing_trie.erl | 18 +++++++++- 5 files changed, 66 insertions(+), 28 deletions(-) diff --git a/guides/configuration.md b/guides/configuration.md index 6b14eee..10e0b1c 100644 --- a/guides/configuration.md +++ b/guides/configuration.md @@ -43,7 +43,7 @@ These parameters can be specified in your *main* application (Eg the one you've |-----|-------------|-------| | `json_lib` | JSON lib to use. Read more in the subsection *Configure json lib* | `atom()` | | `watchers` | Watchers are external programs that will run together with Nova. Watchers are defined as list of tuples where the tuples is in format `{Command, ArgumentList}` (Like `[{my_app, "npm", ["run", "watch"], #{workdir => "priv/assets/js/my-app"}}]`) | `[{string(), string()}] | [{atom(), string(), map()}] | [{atom(), string(), list(), map()}]` | - +| `router_module` | Module that contains the `routes/1` callback | `atom()` | ### Configure json_lib diff --git a/include/nova_router.hrl b/include/nova_router.hrl index 490c018..2ca34ea 100644 --- a/include/nova_router.hrl +++ b/include/nova_router.hrl @@ -6,7 +6,7 @@ callback :: function() | undefined, plugins = [] :: list(), secure = false :: false | {Mod :: atom(), Fun :: atom()}, - extra_state :: any() + extra :: any() }). -record(cowboy_handler_value, { diff --git a/src/nova.erl b/src/nova.erl index b88f259..3f8c0e2 100644 --- a/src/nova.erl +++ b/src/nova.erl @@ -108,6 +108,7 @@ use_stacktrace(_) -> %% the module, function, arity, file, and line number of the %% function call. %% @end +%%-------------------------------------------------------------------- -spec format_stacktrace(Stacktrace :: [{M :: atom(), F :: atom(), A :: integer(), Info :: list()}]) -> [map()]. format_stacktrace(Stacktrace) -> diff --git a/src/nova_router.erl b/src/nova_router.erl index 16d16e1..b06603b 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -1,7 +1,8 @@ %%%------------------------------------------------------------------- %%% @author Niclas Axelsson %%% @doc -%%% +%%% Router module for nova. This module is responsible for compiling routes, dispatching requests to the correct handler +%%% and managing the routing table. It also exposes an API for modulating the routing table at runtime. %%% @end %%%------------------------------------------------------------------- -module(nova_router). @@ -88,15 +89,15 @@ execute(Req = #{host := Host, path := Path, method := Method}, Env) -> {error, comparator_not_found, AllowedMethods} -> logger:debug("Method not allowed: ~p for ~p. Allowed methods: ~p", [Method, Path, AllowedMethods]), %% Join the elements in AllowedMethods with a colon - AllowHeader = iolist_to_binary(string:join([binary_to_list(M) || M <- AllowedMethods], ", ")), + AllowHeader = iolist_to_binary(string:join([unicode:characters_to_list(uri_string:unquote(M)) || M <- AllowedMethods], ", ")), %% Set the 'allow'-header Req1 = cowboy_req:set_resp_header(<<"allow">>, AllowHeader, Req), render_status_page('_', 405, #{error => "Method not allowed"}, Req1, Env); {ok, Bindings, #nova_handler_value{app = App, callback = Callback, secure = Secure, plugins = Plugins, - extra_state = ExtraState}} -> + extra = ExtraState}} -> {ok, Req#{plugins => Plugins, - extra_state => ExtraState, + extra => ExtraState, bindings => Bindings}, Env#{app => App, callback => Callback, @@ -105,10 +106,10 @@ execute(Req = #{host := Host, path := Path, method := Method}, Env) -> } }; {ok, Bindings, #nova_handler_value{app = App, callback = Callback, - secure = Secure, plugins = Plugins, extra_state = ExtraState}, Pathinfo} -> + secure = Secure, plugins = Plugins, extra = ExtraState}, Pathinfo} -> {ok, Req#{plugins => Plugins, - extra_state => ExtraState#{pathinfo => Pathinfo}, + extra => ExtraState#{pathinfo => Pathinfo}, bindings => Bindings}, Env#{app => App, callback => Callback, @@ -252,17 +253,22 @@ compile([{App, Options}|Tl], Dispatch, GlobalOptions) -> compile([App|Tl], Dispatch, Options) -> %% Fetch the router-module for this application Router = - case nova:detect_language() of - erlang -> - %% Router will be app_router - erlang:list_to_atom(io_lib:format("~s_router", [App])); - elixir -> - %% We build the router as App.Router - erlang:list_to_atom(io_lib:format("~s.Router", [App])); - lfe -> - %% We will build the router as app_router here aswell, but might change in the future - erlang:list_to_atom(io_lib:format("~s_router", [App])) + %% The router can be explicitly defined in the application environment, + %% if not we will try to detect it based on the language used in the project + case application:get_env(App, router_module) of + {ok, RouterModule} -> + RouterModule; + undefined -> + case nova:detect_language() of + elixir -> + %% We build the router as App.Router + erlang:list_to_atom(io_lib:format("~s.Router", [App])); + _ -> + %% All other languages are using the app_router convention + erlang:list_to_atom(io_lib:format("~s_router", [App])) + end end, + Env = nova:get_environment(), Routes = get_routes(Router, Env), @@ -329,11 +335,13 @@ compile_paths([RouteInfo|Tl], Dispatch, Options) -> end, Value = #nova_handler_value{secure = Secure, app = App, plugins = normalize_plugins(Plugins), - extra_state = maps:get(extra_state, RouteInfo, #{})}, + extra = maps:get(extra, RouteInfo, #{})}, - Prefix = concat_strings(maps:get(prefix, Options, ""), maps:get(prefix, RouteInfo, "")), + Prefix = concat_strings(maps:get(prefix, Options, ""), + maps:get(prefix, RouteInfo, "")), Host = maps:get(host, RouteInfo, '_'), SubApps = maps:get(apps, RouteInfo, []), + %% We need to add this app info to nova-env NovaEnv = nova:get_env(apps, []), NovaEnv0 = [{App, #{prefix => Prefix}} | NovaEnv], @@ -396,7 +404,7 @@ parse_url(Host, [{RemotePath, LocalPath, Options}|Tl], T = #{prefix := Prefix}, Value0 = #nova_handler_value{ app = App, callback = fun nova_file_controller:TargetFun/1, - extra_state = #{static => Payload, options => Options}, + extra = #{static => Payload, options => Options}, plugins = Value#nova_handler_value.plugins, secure = Secure }, @@ -418,8 +426,8 @@ parse_url(Host, [{Path, Callback, Options}|Tl], T = #{prefix := Prefix}, Value = Methods = maps:get(methods, Options, ['_']), - ExtraState = maps:get(extra_state, Options, undefined), - Value0 = Value#nova_handler_value{extra_state = ExtraState}, + ExtraState = maps:get(extra, Options, undefined), + Value0 = Value#nova_handler_value{extra = ExtraState}, CompiledPaths = lists:foldl( @@ -487,9 +495,9 @@ render_status_page(Host, StatusCode, Data, Req, Env) -> {ok, Bindings, #nova_handler_value{app = App, callback = Callback, secure = Secure, - extra_state = ExtraState}} -> + extra = ExtraState}} -> { - Req#{extra_state => ExtraState, bindings => Bindings, resp_status_code => StatusCode}, + Req#{extra => ExtraState, bindings => Bindings, resp_status_code => StatusCode}, Env#{app => App, callback => Callback, secure => Secure, @@ -546,14 +554,24 @@ method_to_binary(patch) -> <<"PATCH">>; method_to_binary(_) -> '_'. concat_strings(Path1, Path2) when is_binary(Path1) -> - concat_strings(binary_to_list(Path1), Path2); + concat_strings(unicode:characters_to_list(uri_string:unquote(Path1)), Path2); concat_strings(Path1, Path2) when is_binary(Path2) -> - concat_strings(Path1, binary_to_list(Path2)); + concat_strings(Path1, unicode:characters_to_list(uri_string:unquote(Path2))); concat_strings(_Path1, Path2) when is_integer(Path2) -> Path2; concat_strings(Path1, Path2) when is_list(Path1), is_list(Path2) -> string:concat(Path1, Path2). +canonicalise([], Acc) -> + lists:reverse(Acc); +canonicalise([".." | _], []) -> + unsafe; +canonicalise([Seg | Rest], Acc) -> + canonicalise(Rest, [Seg | Acc]). + +%% ============================ +%% Callbacks for nova_router +%% =========================== -spec routes(Env :: atom()) -> [map()]. routes(_) -> [#{ @@ -563,6 +581,9 @@ routes(_) -> ] }]. +%% ============================= +%% Test cases +%% ============================ -ifdef(TEST). -compile(export_all). %% Export all functions for testing purpose -include_lib("eunit/include/eunit.hrl"). diff --git a/src/nova_routing_trie.erl b/src/nova_routing_trie.erl index 2c19e27..5aaa679 100644 --- a/src/nova_routing_trie.erl +++ b/src/nova_routing_trie.erl @@ -242,7 +242,8 @@ lookup(Method0, HostIn, Path, Trie = #{hosts := Hosts}) -> HostTrie -> M = norm_method(Method0), Segs = segs_for_match(Path), - case do_match(Segs, HostTrie, #{}) of + Jailed = check_jailed(Segs), + case do_match(Jailed, HostTrie, #{}) of {ok, Node, Binds} -> case method_payload(M, Node) of {ok, Payload} -> {ok, Node, Payload, Binds}; @@ -390,6 +391,21 @@ gather_routes(Node, AccSegs, Host, Acc0) -> ). %% Methods +check_jailed(Segs) -> + check_jailed(Segs, []). + +check_jailed([], Acc) -> + Acc; +check_jailed([<<"..">>|Tl], [_|Acc]) -> + case Acc of + [] -> + logger:warning("Lookup path tries to escape jail. Ensuring jail boundary is respected."); + _ -> + ok + end, + check_jailed(Tl, Acc); +check_jailed([_|Tl], Acc) -> + check_jailed(Tl, Acc). -spec norm_method(method_in()) -> method(). norm_method(M) when is_binary(M) -> From ce15b44709814e1eced1fccfd6b1e4e25b732dad Mon Sep 17 00:00:00 2001 From: Niclas Axelsson Date: Tue, 19 May 2026 09:12:25 +0200 Subject: [PATCH 14/21] Fix bug where we popped elements pre-mature in jailed-check --- src/nova_routing_trie.erl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nova_routing_trie.erl b/src/nova_routing_trie.erl index 5aaa679..22e23ce 100644 --- a/src/nova_routing_trie.erl +++ b/src/nova_routing_trie.erl @@ -392,7 +392,7 @@ gather_routes(Node, AccSegs, Host, Acc0) -> %% Methods check_jailed(Segs) -> - check_jailed(Segs, []). + lists:reverse(check_jailed(Segs, [])). check_jailed([], Acc) -> Acc; @@ -404,8 +404,8 @@ check_jailed([<<"..">>|Tl], [_|Acc]) -> ok end, check_jailed(Tl, Acc); -check_jailed([_|Tl], Acc) -> - check_jailed(Tl, Acc). +check_jailed([Hd|Tl], Acc) -> + check_jailed(Tl, [Hd|Acc]). -spec norm_method(method_in()) -> method(). norm_method(M) when is_binary(M) -> From ff2124304b6b16828a01b497ea5a7f76083b31f8 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 10 Aug 2026 11:37:16 +0200 Subject: [PATCH 15/21] feat(router): bring nova_routing_trie up to routing_tree parity The trie could not yet stand in for routing_tree. This closes the gaps and freezes the contract nova_router is written against. Added: - find/4, the routing entry point, returning routing_tree's shapes: {ok, Bindings, Payload} | {ok, Bindings, Payload, PathInfo} | {error, not_found} | {error, comparator_not_found, AllowedMethods}. The last one is what Nova turns into a 405, and it had no way to say it. - Integer paths for HTTP status codes. nova_router:routes/1 registers 404 and 500 on every boot, so without this no Nova application starts at all. - The [...] catch-all and its PathInfo, rejected when it is not the last segment. Every static-file route and the rebar3_nova scaffold needs it. - '_' as the any-method comparator, matching what nova_router already passes. It previously normalised to <<"_">> while the sentinel was <<"ALL">>, so every websocket, static and methods => ['_'] route was unmatchable. - routes/1 as the supported introspection API. The trie is opaque, so consumers need a stable shape rather than the internal map. - on_duplicate, so add_routes/2 can overwrite as documented while compile keeps routing_tree's first-wins default. Fixed: - new/1 normalises its options. nova_router passed #{options => #{strict => X}} into a function that stored it verbatim, so use_strict_routing never reached the trie and strict mode was dead code. - Matching backtracks past a literal that carries no payload, and tries every binding sibling rather than whichever one maps:fold visited first. With N bindings at one depth, N-1 were unreachable and which one survived depended on hash order. - Ambiguity detection only runs under strict mode. It fired on /users/new alongside /users/:id, which is ordinary REST, and would have warned on startup for most applications. - Non-strict inserts no longer walk the trie twice, once to detect conflicts and again to build. - ".." is resolved and clamped at the root. The old check ate the root sentinel, so /../a became unroutable and logged a warning per request on attacker-controlled input. - Query strings and fragments are stripped, and a pre-split segment list is accepted, both of which routing_tree handled. - Conflicts log through the logger instead of io:format. Removed the unused per-node plugins and options fields, the commented-out method_member/2, and lookup/2,3,4, which returned an opaque node. test/nova_routing_trie_tests.erl ports routing_tree's own suite as the parity checklist and adds coverage for the behaviour that is deliberately different. --- src/nova_routing_trie.erl | 1391 +++++++++++------------------- test/nova_routing_trie_tests.erl | 400 +++++++++ 2 files changed, 914 insertions(+), 877 deletions(-) create mode 100644 test/nova_routing_trie_tests.erl diff --git a/src/nova_routing_trie.erl b/src/nova_routing_trie.erl index 22e23ce..8cebba7 100644 --- a/src/nova_routing_trie.erl +++ b/src/nova_routing_trie.erl @@ -1,94 +1,107 @@ +%%%------------------------------------------------------------------- +%%% @author Niclas Axelsson +%%% @doc +%%% Host-aware routing trie. This is Nova's dispatch table: a map from +%%% host to a tree of path segments, where each terminal node holds one +%%% payload per comparator (HTTP method). +%%% +%%% Paths are declared with three kinds of segment: +%%% +%%%
    +%%%
  • `"/users"' - a literal segment.
  • +%%%
  • `"/users/:id"' - a binding. Matches one segment and binds it +%%% under `<<"id">>' in the returned bindings map.
  • +%%%
  • `"/assets/[...]"' - a catch-all. Matches zero or more trailing +%%% segments, which are returned as `PathInfo'. Only valid as the +%%% last segment of a path.
  • +%%%
+%%% +%%% A path may also be an integer, in which case it denotes an HTTP status +%%% code rather than a URL. Nova uses this to register error pages. +%%% +%%% Matching is deterministic: at each depth a literal segment is tried +%%% first, then each binding in name order, then the catch-all. Matching +%%% backtracks, so `"/a/:x/c"' still matches `"/a/b/c"' even when a +%%% `"/a/b/d"' route exists. +%%% @end +%%%------------------------------------------------------------------- -module(nova_routing_trie). -export([ new/0, new/1, - %% Transform / rebuild from routes - foldl/2, - - %% Insert insert/4, insert/5, insert/6, - %% Membership - member/2, + find/4, + member/3, member/4, - %% Unified lookup - lookup/2, - lookup/3, - lookup/4, - + routes/1, to_list/1, - from_list/1 + from_list/1, + from_list/2, + foldl/2 ]). -%% Host-aware trie: -%% - 'trie()' is the host root -%% - each host key maps to a per-host trie_node() (routing tree) +-include_lib("kernel/include/logger.hrl"). + +-define(ROOT, <<"/">>). + -opaque trie() :: #{ - options := map(), %% global/root options - hosts := #{ host_key() => trie_node() } + options := options(), + hosts := #{host_key() => trie_node()} }. -%% Per-host routing tree node -%% - children: nested segments -%% - terminal: map MethodBin => Payload -opaque trie_node() :: #{ - options := map(), %% options for this node - children := #{ child_key() => trie_node() }, - plugins := #{ - pre_request := [module()], - post_request := [module()] - }, - terminal := #{ method() => payload() } + children := #{child_key() => trie_node()}, + terminal := #{comparator() => payload()} }. --type child_key() :: binary() | {wild, binary()}. +-type options() :: #{ + strict := boolean(), + on_duplicate := keep_first | overwrite, + atom() => term() + }. + +-type child_key() :: binary() | {binding, binary()} | '...' | integer(). -type host_key() :: '_' | binary(). -type host_in() :: '_' | binary() | list() | atom(). -%% Stored method representation: uppercase binary, e.g. <<"GET">>, <<"ALL">> --type method() :: binary(). +%% A path is either a URL or an HTTP status code. +-type path() :: binary() | list() | integer(). -%% External method input: atom (get), binary (<<"GET">>), or string --type method_in() :: method() | atom() | list(). +%% Stored comparator: '_' matches any, otherwise an uppercase method binary. +-type comparator() :: '_' | binary(). +-type comparator_in() :: comparator() | atom() | list(). --type payload() :: term(). +-type payload() :: term(). +-type bindings() :: #{binary() => binary()}. -%% A "route" is the canonical unit we can rebuild the trie from. -%% -%% Supported input shapes: -%% {Path, Method, Payload} -%% {Path, Method, Payload, Opts} -%% {Host, Path, Method, Payload} -%% {Host, Path, Method, Payload, Opts} -%% -%% Where: -%% - Host is optional (defaults to '_') -%% - Opts is optional (defaults to #{}) and is merged into trie options for that insertion +%% The canonical unit the trie can be rebuilt from. Note that the host form +%% is unambiguous: a route is always a 4-tuple unless per-insert options are +%% supplied, in which case it is a 5-tuple. -type route() :: - {iodata(), method_in(), payload()} | - {iodata(), method_in(), payload(), map()} | - {host_in(), iodata(), method_in(), payload()} | - {host_in(), iodata(), method_in(), payload(), map()}. + {path(), comparator_in(), payload()} | + {host_in(), path(), comparator_in(), payload()} | + {host_in(), path(), comparator_in(), payload(), map()}. -type conflict() :: #{ - reason := atom(), - at := [child_key()], - existing := child_key() | undefined, - incoming := child_key() | undefined, - conflicts_with := binary(), - incoming_path := binary(), - method := method(), - existing_methods:= [method()] + reason := atom(), + at := [child_key()], + existing := child_key() | undefined, + incoming := child_key() | undefined, + conflicts_with := path(), + incoming_path := path(), + comparator := comparator(), + existing_methods := [comparator()] }. --export_type([trie/0, trie_node/0, method/0]). +-export_type([trie/0, trie_node/0, route/0, bindings/0, comparator/0, conflict/0]). %%==================================================================== %% API @@ -98,900 +111,524 @@ new() -> new(#{}). +%%-------------------------------------------------------------------- +%% @doc +%% Create an empty trie. Recognised options are `strict' (also accepted +%% as `use_strict' for compatibility) and `on_duplicate', which is either +%% `keep_first' (the default) or `overwrite'. +%% @end +%%-------------------------------------------------------------------- -spec new(map()) -> trie(). new(Opts) when is_map(Opts) -> - #{options => Opts, hosts => #{}}. + #{options => norm_options(Opts), hosts => #{}}. %%-------------------------------------------------------------------- -%% Insert -%% -%% Canonical full form: -%% insert(Host, Path, Method, Payload, Trie, Opts) +%% @doc +%% Insert a route. `Path' is a URL or an HTTP status code, `Comparator' +%% is an HTTP method or `'_'' to match any method. %% -%% Convenience: -%% insert(Path, Method, Payload, Trie). % Host='_', Opts=#{} -%% insert(Host, Path, Method, Payload, Trie). % Opts=#{} +%% Returns `{error, conflict, Conflict}' when the trie is in strict mode +%% and the route clashes with an existing one. In non-strict mode a clash +%% is logged and resolved according to the `on_duplicate' option. +%% @end %%-------------------------------------------------------------------- - --spec insert(iodata(), method_in(), payload(), trie()) -> +-spec insert(path(), comparator_in(), payload(), trie()) -> {ok, trie()} | {error, conflict, conflict()}. -insert(Path, Method, Payload, Trie) -> - insert('_', Path, Method, Payload, Trie, #{}). +insert(Path, Comparator, Payload, Trie) -> + insert('_', Path, Comparator, Payload, Trie, #{}). --spec insert(host_in(), iodata(), method_in(), payload(), trie()) -> +-spec insert(host_in(), path(), comparator_in(), payload(), trie()) -> {ok, trie()} | {error, conflict, conflict()}. -insert(HostIn, Path, Method, Payload, Trie) -> - insert(HostIn, Path, Method, Payload, Trie, #{}). +insert(HostIn, Path, Comparator, Payload, Trie) -> + insert(HostIn, Path, Comparator, Payload, Trie, #{}). --spec insert(host_in(), iodata(), method_in(), payload(), trie(), map()) -> +-spec insert(host_in(), path(), comparator_in(), payload(), trie(), map()) -> {ok, trie()} | {error, conflict, conflict()}. -insert(HostIn, Path, Method, Payload, Trie0, Opts) -> +insert(HostIn, Path, ComparatorIn, Payload, Trie = #{options := RootOpts, hosts := Hosts}, Opts) -> Host = norm_host(HostIn), - RootOpts0 = maps:get(options, Trie0, #{}), - RootOpts1 = maps:merge(RootOpts0, Opts), - Trie1 = Trie0#{options => RootOpts1}, - Hosts = maps:get(hosts, Trie1, #{}), - {Trie2, HostTrie0} = - case maps:get(Host, Hosts, undefined) of - undefined -> - HostTrie = new_node(RootOpts1), - Hosts1 = Hosts#{Host => HostTrie}, - {Trie1#{hosts := Hosts1}, HostTrie}; - HostTrie -> - {Trie1, HostTrie} - end, - case insert_inner(Method, Path, Payload, HostTrie0, RootOpts1) of - {ok, HostTrie1} -> - Hosts2 = maps:get(hosts, Trie2, #{}), - Hosts3 = Hosts2#{Host => HostTrie1}, - {ok, Trie2#{hosts := Hosts3}}; - {error, conflict, Conf} -> - {error, conflict, Conf} - end. - -%% Internal: insert into a single host's routing trie --spec insert_inner(method_in(), iodata(), payload(), trie_node(), map()) -> - {ok, trie_node()} | {error, conflict, conflict()}. -insert_inner(Method, Path, Payload, TrieNode, Opts) -> - Opts0 = maps:get(options, TrieNode, #{}), - Opts1 = maps:merge(Opts0, Opts), - Trie1 = TrieNode#{options := Opts1}, - - M = norm_method(Method), - Segs = segs_for_insert(Path), - Strict = maps:get(strict, Opts1, false), - - case Strict of - true -> - %% Strict: conflicts become errors - safe_insert_segs(M, Payload, Segs, Trie1, [], Segs); - false -> - %% Non-strict: run safe_insert_segs only for detection, - %% then always perform regular insert_segs. Conflicts -> warnings. - case safe_insert_segs(M, Payload, Segs, Trie1, [], Segs) of - {error, conflict, Conf} -> - warn_conflict(Conf), - {ok, insert_segs(M, Payload, Segs, Trie1)}; - {ok, _TmpNode} -> - {ok, insert_segs(M, Payload, Segs, Trie1)} - end + Comparator = norm_comparator(ComparatorIn), + Options = norm_options(maps:merge(RootOpts, Opts)), + Segments = parse_path(Path), + HostTrie = maps:get(Host, Hosts, new_node()), + case do_insert(Segments, Comparator, Payload, HostTrie, Options, [], Segments) of + {ok, HostTrie0} -> + {ok, Trie#{hosts := Hosts#{Host => HostTrie0}}}; + {error, conflict, _Conflict} = Error -> + Error end. %%-------------------------------------------------------------------- -%% Warnings (non-strict conflicts) -%%-------------------------------------------------------------------- -warn_conflict(Conf) -> - Reason = maps:get(reason, Conf), - Method = maps:get(method, Conf), - IncomingPath = maps:get(incoming_path, Conf), - ConflictsWith = maps:get(conflicts_with, Conf), - io:format( - "routing_trie warning (~p): ~s ~s conflicts with ~s~n", - [Reason, render_method(Method), IncomingPath, ConflictsWith] - ). - -%%-------------------------------------------------------------------- -%% Membership (uses lookup) +%% @doc +%% Look up a route. +%% +%% A host-specific tree is consulted when one exists for `Host', and the +%% `'_'' tree otherwise. `PathInfo' is only returned when the match was +%% made by a `[...]' catch-all and there were trailing segments to report. +%% +%% `{error, comparator_not_found, AllowedMethods}' means the path matched +%% but the method did not, which is what Nova turns into a 405. +%% @end %%-------------------------------------------------------------------- +-spec find(host_in(), path(), comparator_in(), trie()) -> + {ok, bindings(), payload()} | + {ok, bindings(), payload(), PathInfo :: [binary()]} | + {error, not_found} | + {error, comparator_not_found, [comparator()]}. +find(HostIn, Path, ComparatorIn, Trie) -> + case host_trie(norm_host(HostIn), Trie) of + error -> + {error, not_found}; + {ok, HostTrie} -> + case match(parse_lookup_path(Path), HostTrie, #{}) of + error -> + {error, not_found}; + {ok, #{terminal := Terminal}, Bindings, PathInfo} -> + case resolve(norm_comparator(ComparatorIn), Terminal) of + {ok, Payload} when PathInfo =:= [] -> + {ok, Bindings, Payload}; + {ok, Payload} -> + {ok, Bindings, Payload, PathInfo}; + Error -> + Error + end + end + end. --spec member(iodata(), trie()) -> boolean(). -member(Path, Trie) -> - member(<<"ALL">>, Path, Trie). - --spec member(method_in(), iodata(), trie()) -> boolean(). -member(Method0, Path, Trie) -> - member(Method0, '_', Path, Trie). +-spec member(host_in(), path(), trie()) -> boolean(). +member(HostIn, Path, Trie) -> + member(HostIn, Path, '_', Trie). --spec member(method_in(), host_in(), iodata(), trie()) -> boolean(). -member(Method0, HostIn, Path, Trie) -> - case lookup(Method0, HostIn, Path, Trie) of - {ok, _Node, _Payload, _Binds} -> true; - error -> false +-spec member(host_in(), path(), comparator_in(), trie()) -> boolean(). +member(HostIn, Path, Comparator, Trie) -> + case find(HostIn, Path, Comparator, Trie) of + {ok, _Bindings, _Payload} -> true; + {ok, _Bindings, _Payload, _PathInfo} -> true; + _ -> false end. %%-------------------------------------------------------------------- -%% Unified lookup -%% -%% - Performs host selection (with '_' fallback) -%% - Performs path + wildcard matching -%% - Chooses payload according to Method (method-specific or <<"ALL">>) -%% - Returns: -%% error -%% | {ok, Node, Payload, Bindings} +%% @doc +%% Every route in the trie, as `{Host, Path, Comparator, Payload}'. This +%% is the supported way to introspect a dispatch table; the trie itself is +%% opaque. The result can be fed straight back into {@link from_list/1}. +%% @end %%-------------------------------------------------------------------- - --spec lookup(iodata(), trie()) -> - {ok, trie_node(), term(), #{binary() => binary()}} | error. -lookup(Path, Trie) -> - lookup(<<"ALL">>, '_', Path, Trie). - --spec lookup(method_in(), iodata(), trie()) -> - {ok, trie_node(), term(), #{binary() => binary()}} | error. -lookup(Method0, Path, Trie) -> - lookup(Method0, '_', Path, Trie). - --spec lookup(method_in(), host_in(), iodata(), trie()) -> - {ok, trie_node(), term(), #{binary() => binary()}} | error. -lookup(Method0, HostIn, Path, Trie = #{hosts := Hosts}) -> - Host = norm_host(HostIn), - case maps:get(Host, Hosts, undefined) of - undefined when Host =/= '_' -> - lookup(Method0, '_', Path, Trie); - undefined -> - error; - HostTrie -> - M = norm_method(Method0), - Segs = segs_for_match(Path), - Jailed = check_jailed(Segs), - case do_match(Jailed, HostTrie, #{}) of - {ok, Node, Binds} -> - case method_payload(M, Node) of - {ok, Payload} -> {ok, Node, Payload, Binds}; - error -> error - end; - _ -> - error - end - end. - +-spec routes(trie()) -> [route()]. +routes(#{hosts := Hosts}) -> + gather_routes(maps:to_list(Hosts), []). %%-------------------------------------------------------------------- -%% to_list: flattened host view (ignores payloads) +%% @doc +%% A flat, human-readable listing of the routing table, one entry per +%% method and path. Payloads are not included - use {@link routes/1} when +%% you need them. +%% @end %%-------------------------------------------------------------------- - -spec to_list(trie()) -> [binary()]. to_list(Trie) -> - Hosts = maps:get(hosts, Trie, #{}), - maps:fold( - fun(_Host, HostTrie, Acc) -> - gather(HostTrie, [], Acc) - end, [], Hosts). - -%%-------------------------------------------------------------------- -%% from_list: build a routing trie from a list of routes -%%-------------------------------------------------------------------- + [render_route(Comparator, Path) || {_Host, Path, Comparator, _Payload} <- routes(Trie)]. -spec from_list([route()]) -> {ok, trie()} | {error, conflict, conflict()}. -from_list(Routes) when is_list(Routes) -> +from_list(Routes) -> from_list(Routes, #{}). +-spec from_list([route()], map()) -> {ok, trie()} | {error, conflict, conflict()}. +from_list(Routes, RootOpts) when is_list(Routes), is_map(RootOpts) -> + insert_routes(Routes, new(RootOpts)). + %%-------------------------------------------------------------------- -%% foldl: rebuild the trie by transforming its extracted routes -%% -%% Function :: fun(([route()]) -> [route()]) -%% -%% 1) Extracts routes from the trie (including payloads) -%% 2) Calls Function(Routes) -%% 3) Rebuilds a new trie from the returned routes +%% @doc +%% Rebuild the trie from a transformation of its routes. `Fun' is handed +%% every route in the table and returns the routes the new table should +%% contain, which makes it the way to filter or rewrite the dispatch table +%% wholesale. +%% @end %%-------------------------------------------------------------------- - -spec foldl(trie(), fun(([route()]) -> [route()])) -> {ok, trie()} | {error, conflict, conflict()}. -foldl(Trie0, Fun) when is_map(Trie0), is_function(Fun, 1) -> - Routes0 = routes(Trie0), - Routes1 = Fun(Routes0), - case is_list(Routes1) of - true -> - from_list(Routes1, maps:get(options, Trie0, #{})); - false -> - erlang:error({badreturn, {foldl, Fun, Routes1}}) +foldl(Trie = #{options := Options}, Fun) when is_function(Fun, 1) -> + case Fun(routes(Trie)) of + Routes when is_list(Routes) -> + from_list(Routes, Options); + Other -> + erlang:error({badreturn, {foldl, Fun, Other}}) end. %%==================================================================== -%% Internal functions (methods, segments, conflicts, matching) +%% Internal functions - construction %%==================================================================== -%%-------------------------------------------------------------------- -%% from_list helpers -%%-------------------------------------------------------------------- - --spec from_list([route()], map()) -> {ok, trie()} | {error, conflict, conflict()}. -from_list(Routes, RootOpts) when is_list(Routes), is_map(RootOpts) -> - lists:foldl( - fun - (Route, {ok, TrieAcc}) -> - insert_route(Route, TrieAcc); - (_Route, Err={error, conflict, _Conf}) -> - Err - end, - {ok, new(RootOpts)}, - Routes - ). - --spec insert_route(route(), trie()) -> {ok, trie()} | {error, conflict, conflict()}. -insert_route({Path, Method, Payload}, Trie) -> - insert(Path, Method, Payload, Trie); -insert_route({Path, Method, Payload, Opts}, Trie) when is_map(Opts) -> - insert('_', Path, Method, Payload, Trie, Opts); -insert_route({Host, Path, Method, Payload}, Trie) -> - insert(Host, Path, Method, Payload, Trie); -insert_route({Host, Path, Method, Payload, Opts}, Trie) when is_map(Opts) -> - insert(Host, Path, Method, Payload, Trie, Opts); -insert_route(Other, _Trie) -> - erlang:error({bad_route, Other}). - -%%-------------------------------------------------------------------- -%% Route extraction (used by foldl/2) -%%-------------------------------------------------------------------- - --spec routes(trie()) -> [route()]. -routes(Trie) -> - Hosts = maps:get(hosts, Trie, #{}), - maps:fold( - fun(Host, HostTrie, Acc) -> - gather_routes(HostTrie, [], Host, Acc) - end, - [], - Hosts - ). +-spec new_node() -> trie_node(). +new_node() -> + #{children => #{}, terminal => #{}}. + +-spec norm_options(map()) -> options(). +norm_options(Opts) -> + Strict = maps:get(strict, Opts, maps:get(use_strict, Opts, false)) =:= true, + OnDuplicate = + case maps:get(on_duplicate, Opts, keep_first) of + overwrite -> overwrite; + _ -> keep_first + end, + Opts#{strict => Strict, on_duplicate => OnDuplicate}. +-spec norm_host(host_in()) -> host_key(). +norm_host('_') -> '_'; +norm_host(Host) when is_binary(Host) -> Host; +norm_host(Host) when is_list(Host) -> unicode:characters_to_binary(Host); +norm_host(Host) when is_atom(Host) -> atom_to_binary(Host, utf8). +-spec norm_comparator(comparator_in()) -> comparator(). +norm_comparator('_') -> '_'; +norm_comparator(C) when is_binary(C) -> string:uppercase(C); +norm_comparator(C) when is_atom(C) -> string:uppercase(atom_to_binary(C, utf8)); +norm_comparator(C) when is_list(C) -> string:uppercase(unicode:characters_to_binary(C)). %%==================================================================== -%% Internal functions +%% Internal functions - path parsing %%==================================================================== --spec new_node() -> trie_node(). -new_node() -> - new_node(#{}). --spec new_node(map()) -> trie_node(). -new_node(Opts) when is_map(Opts) -> - #{children => #{}, terminal => #{}, options => Opts, plugins => #{pre_request => [], - post_request => []}}. - --spec norm_host(host_in()) -> host_key(). -norm_host('_') -> - '_'; -norm_host(Host) when is_binary(Host) -> - Host; -norm_host(Host) when is_list(Host) -> - list_to_binary(Host); -norm_host(Host) when is_atom(Host) -> - list_to_binary(atom_to_list(Host)). - - -gather_routes(Node, AccSegs, Host, Acc0) -> - Term = maps:get(terminal, Node, #{}), - Acc1 = - maps:fold( - fun(M, Payload, A) -> - Path = render_path(AccSegs), - [{Host, Path, M, Payload} | A] - end, - Acc0, - Term - ), - Cs = maps:get(children, Node, #{}), - maps:fold( - fun(K, Child, A) -> - gather_routes(Child, AccSegs ++ [K], Host, A) - end, - Acc1, - Cs - ). - -%% Methods -check_jailed(Segs) -> - lists:reverse(check_jailed(Segs, [])). - -check_jailed([], Acc) -> - Acc; -check_jailed([<<"..">>|Tl], [_|Acc]) -> - case Acc of - [] -> - logger:warning("Lookup path tries to escape jail. Ensuring jail boundary is respected."); - _ -> - ok - end, - check_jailed(Tl, Acc); -check_jailed([Hd|Tl], Acc) -> - check_jailed(Tl, [Hd|Acc]). - --spec norm_method(method_in()) -> method(). -norm_method(M) when is_binary(M) -> - %% Assume already normalized (e.g. <<"GET">>, <<"POST">>, <<"ALL">>) - M; -norm_method(M) when is_atom(M) -> - list_to_binary(string:uppercase(atom_to_list(M))); -norm_method(M) when is_list(M) -> - list_to_binary(string:uppercase(M)). - -terminal_add(M, Payload, Node0=#{terminal := Term0}) -> - Node0#{terminal := Term0#{ M => Payload }}. - -%% Resolve payload for a given method at a terminal node --spec method_payload(method(), trie_node()) -> {ok, term()} | error. -method_payload(M, #{terminal := Term}) -> - case M of - <<"ALL">> -> - case maps:find(<<"ALL">>, Term) of - {ok, Payload} -> - {ok, Payload}; - error -> - case maps:to_list(Term) of - [] -> error; - [{_, P} | _] -> {ok, P} - end - end; - _ -> - case maps:find(M, Term) of - {ok, Payload} -> - {ok, Payload}; - error -> - case maps:find(<<"ALL">>, Term) of - {ok, Payload} -> {ok, Payload}; - error -> error - end - end +%% Parse a declared route path into trie keys. +-spec parse_path(path()) -> [child_key()]. +parse_path(StatusCode) when is_integer(StatusCode) -> + [StatusCode]; +parse_path(Path) when is_list(Path) -> + parse_path(unicode:characters_to_binary(Path)); +parse_path(Path) when is_binary(Path) -> + [?ROOT | to_keys(split(Path), [])]; +parse_path(Path) -> + throw({error, {badly_formed_route, Path}}). + +to_keys([], Acc) -> + lists:reverse(Acc); +to_keys([<<"[...]">>], Acc) -> + lists:reverse(['...' | Acc]); +to_keys([<<"[...]">> | _Tl], _Acc) -> + throw({bad_routingfile, wildcard_not_last_in_path}); +to_keys([<<":", Name/binary>> | Tl], Acc) -> + to_keys(Tl, [{binding, Name} | Acc]); +to_keys([Segment | Tl], Acc) -> + to_keys(Tl, [Segment | Acc]). + +%% Parse an incoming request path into segments to match against. +-spec parse_lookup_path(path()) -> [binary() | integer()]. +parse_lookup_path(StatusCode) when is_integer(StatusCode) -> + [StatusCode]; +parse_lookup_path(Path) when is_binary(Path) -> + [?ROOT | canonicalise(split(Path), [])]; +parse_lookup_path(Path) when is_list(Path) -> + case lists:all(fun erlang:is_integer/1, Path) of + true -> + %% A flat string. + parse_lookup_path(unicode:characters_to_binary(Path)); + false -> + %% Already-split segments. + Segments = [seg_to_binary(S) || S <- Path], + [?ROOT | canonicalise([S || S <- Segments, S =/= <<>>], [])] end. -%% method_member(M, Node) -> -%% case method_payload(M, Node) of -%% {ok, _} -> true; -%% error -> false -%% end. +seg_to_binary(S) when is_binary(S) -> S; +seg_to_binary(S) when is_list(S) -> unicode:characters_to_binary(S); +seg_to_binary(S) when is_atom(S) -> atom_to_binary(S, utf8). -methods_list(#{terminal := Term}) -> - [K || {K, _} <- maps:to_list(Term)]. +split(Path) -> + [S || S <- binary:split(strip_query(Path), <<"/">>, [global]), S =/= <<>>]. + +strip_query(Path) -> + case binary:match(Path, [<<"?">>, <<"#">>]) of + nomatch -> Path; + {Pos, _Len} -> binary:part(Path, 0, Pos) + end. -render_method(M) when is_binary(M) -> - M; -render_method(M) -> - norm_method(M). +%% Resolve "." and ".." within a request path, clamped at the root so a +%% request can never traverse above it. +canonicalise([], Acc) -> + lists:reverse(Acc); +canonicalise([<<".">> | Tl], Acc) -> + canonicalise(Tl, Acc); +canonicalise([<<"..">> | Tl], []) -> + canonicalise(Tl, []); +canonicalise([<<"..">> | Tl], [_Popped | Acc]) -> + canonicalise(Tl, Acc); +canonicalise([Segment | Tl], Acc) -> + canonicalise(Tl, [Segment | Acc]). -%% Segments and paths +%%==================================================================== +%% Internal functions - insertion +%%==================================================================== + +do_insert([], Comparator, Payload, Node = #{terminal := Terminal}, Options, Prefix, Full) -> + case duplicate_reason(Comparator, Terminal) of + none -> + {ok, Node#{terminal := Terminal#{Comparator => Payload}}}; + Reason -> + Conflict = conflict(Reason, Prefix, undefined, undefined, render_path(Prefix), + render_path(Full), Comparator, maps:keys(Terminal)), + case Options of + #{strict := true} -> + {error, conflict, Conflict}; + #{on_duplicate := overwrite} -> + warn_conflict(Conflict), + {ok, Node#{terminal := Terminal#{Comparator => Payload}}}; + _ -> + warn_conflict(Conflict), + {ok, Node} + end + end; +do_insert([Key | Rest], Comparator, Payload, Node = #{children := Children}, Options, Prefix, Full) -> + case ambiguity(Key, Rest, Comparator, Children, Prefix, Full, Options) of + {error, conflict, _Conflict} = Error -> + Error; + ok -> + Child = maps:get(Key, Children, new_node()), + case do_insert(Rest, Comparator, Payload, Child, Options, Prefix ++ [Key], Full) of + {ok, Child0} -> + {ok, Node#{children := Children#{Key => Child0}}}; + Error -> + Error + end + end. -segs_for_insert(Path) when is_list(Path) -> - segs_for_insert(list_to_binary(Path)); -segs_for_insert(Path) when is_binary(Path) -> - Parts = [S || S <- binary:split(Path, <<"/">>, [global, trim]), S =/= <<>>], - [<<"/">> | [ to_key(S) || S <- Parts ]]. +%% A terminal already carrying this comparator is a duplicate route, and so is +%% a concrete method arriving at a terminal that already answers every method. +%% +%% The reverse - '_' arriving where concrete methods already exist - is not a +%% duplicate: both are kept and resolve/2 gives the concrete method priority. +duplicate_reason(Comparator, Terminal) -> + case maps:is_key(Comparator, Terminal) of + true -> + duplicate_pattern; + false when Comparator =/= '_' -> + case maps:is_key('_', Terminal) of + true -> duplicate_due_to_all; + false -> none + end; + false -> + none + end. -segs_for_match(Path) when is_list(Path) -> - segs_for_match(list_to_binary(Path)); -segs_for_match(Path) when is_binary(Path) -> - Parts = [S || S <- binary:split(Path, <<"/">>, [global, trim]), S =/= <<>>], - [<<"/">> | Parts]. +%% Overlapping literal/binding routes at the same depth are ambiguous to a +%% reader even though matching resolves them deterministically. Only report +%% that under strict mode - `/users/new' alongside `/users/:id' is an +%% entirely ordinary thing to write. +ambiguity(_Key, _Rest, _Comparator, _Children, _Prefix, _Full, #{strict := false}) -> + ok; +ambiguity(_Key, Rest, _Comparator, _Children, _Prefix, _Full, _Options) when Rest =/= [] -> + ok; +ambiguity({binding, Name}, _Rest, Comparator, Children, Prefix, Full, _Options) -> + case overshadowed_literal(Comparator, Children) of + {ok, Literal, Methods} -> + {error, conflict, conflict(overshadowing_route, Prefix, Literal, {binding, Name}, + render_path(Prefix ++ [Literal]), render_path(Full), + Comparator, Methods)}; + none -> + case [N || {binding, N} <- maps:keys(Children), N =/= Name] of + [Existing | _] -> + {error, conflict, conflict(binding_name_conflict, Prefix, {binding, Existing}, + {binding, Name}, + render_path(Prefix ++ [{binding, Existing}]), + render_path(Full), Comparator, + methods(maps:get({binding, Existing}, Children)))}; + [] -> + ok + end + end; +ambiguity(Key, _Rest, Comparator, Children, Prefix, Full, _Options) -> + case overshadowed_binding(Comparator, Children) of + {ok, Binding, Methods} -> + {error, conflict, conflict(overshadowing_route, Prefix, Binding, Key, + render_path(Prefix ++ [Binding]), render_path(Full), + Comparator, Methods)}; + none -> + ok + end. -to_key(<<":", Rest/binary>>) -> {wild, Rest}; -to_key(Bin) -> Bin. +overshadowed_literal(Comparator, Children) -> + overshadowed(Comparator, [{K, C} || {K, C} <- maps:to_list(Children), is_binary(K)]). -render_key({wild, Name}) -> <<":", Name/binary>>; -render_key(Bin) -> Bin. +overshadowed_binding(Comparator, Children) -> + overshadowed(Comparator, [{K, C} || K = {binding, _} := C <- Children]). -%% Older-Erlang-safe binary join --spec join_with_sep([binary()], binary()) -> binary(). -join_with_sep([], _Sep) -> - <<>>; -join_with_sep([First | Rest], Sep) -> - iolist_to_binary( - lists:foldl( - fun(Bin, Acc) -> [Acc, Sep, Bin] end, - First, - Rest - )). - -render_path(Segs) -> - case Segs of - [] -> <<"/">>; - [<<"/">>] -> <<"/">>; - [<<"/">> | Rest] -> - RestBins = [render_key(S) || S <- Rest], - <<"/", (join_with_sep(RestBins, <<"/">>))/binary>>; - _ -> - join_with_sep([render_key(S) || S <- Segs], <<"/">>) +overshadowed(_Comparator, []) -> + none; +overshadowed(Comparator, [{Key, Child} | Tl]) -> + Methods = methods(Child), + case overlaps(Comparator, Methods) of + true -> {ok, Key, Methods}; + false -> overshadowed(Comparator, Tl) end. -%% Insert without conflict checking (single-host trie) - -insert_segs(M, Payload, [], N0) -> - terminal_add(M, Payload, N0); -insert_segs(M, Payload, [K | Rest], N0) -> - Cs0 = maps:get(children, N0), - Child0 = maps:get(K, Cs0, new_node()), - Child1 = insert_segs(M, Payload, Rest, Child0), - N0#{children := maps:put(K, Child1, Cs0)}. - -%% Conflict helpers - -%% Find any wildcard child -find_wild_child(Cs) -> - maps:fold( - fun - ({wild, Var}, Child, none) -> {wild, Var, Child}; - (_K, _Child, Acc) -> Acc - end, none, Cs). - -%% Find static child that will be affected by new wildcard at same depth -find_static_overshadow_child(Cs, M) -> - maps:fold( - fun - ({wild, _}, _Child, Acc) -> - Acc; - (K, Child, none) -> - Ms = methods_list(Child), - case overshadow_methods(M, Ms) of - true -> {found, {K, Child, Ms}}; - false -> none - end; - (_K, _Child, Acc) -> - Acc - end, none, Cs). - -%% Does method M conflict (overlap) with existing methods? -overshadow_methods(M, ExistingMs) -> - case M of - <<"ALL">> -> - ExistingMs =/= []; - _ -> - lists:member(M, ExistingMs) orelse - lists:member(<<"ALL">>, ExistingMs) +overlaps('_', Methods) -> + Methods =/= []; +overlaps(Comparator, Methods) -> + lists:member(Comparator, Methods) orelse lists:member('_', Methods). + +methods(#{terminal := Terminal}) -> + maps:keys(Terminal). + +conflict(Reason, At, Existing, Incoming, ConflictsWith, IncomingPath, Comparator, Methods) -> + #{reason => Reason, + at => At, + existing => Existing, + incoming => Incoming, + conflicts_with => ConflictsWith, + incoming_path => IncomingPath, + comparator => Comparator, + existing_methods => Methods}. + +warn_conflict(#{reason := Reason, comparator := Comparator, incoming_path := IncomingPath, + conflicts_with := ConflictsWith}) -> + ?LOG_WARNING(#{msg => <<"Conflicting route">>, + reason => Reason, + method => Comparator, + route => IncomingPath, + conflicts_with => ConflictsWith}). + +insert_routes([], Trie) -> + {ok, Trie}; +insert_routes([Route | Tl], Trie) -> + case insert_route(Route, Trie) of + {ok, Trie0} -> insert_routes(Tl, Trie0); + {error, conflict, _Conflict} = Err -> Err end. -%% Safe insert with conflict checking (single-host trie) -safe_insert_segs(M, Payload, [], N0, Prefix, Full) -> - Ms = methods_list(N0), - case lists:member(M, Ms) of - true -> - {error, conflict, #{ - reason => duplicate_pattern, - at => Prefix, - existing => undefined, - incoming => undefined, - conflicts_with => render_path(Prefix), - incoming_path => render_path(Full), - method => M, - existing_methods=> Ms - }}; - false -> - case lists:member(<<"ALL">>, Ms) of - true when M =/= <<"ALL">> -> - {error, conflict, #{ - reason => duplicate_due_to_all, - at => Prefix, - existing => undefined, - incoming => undefined, - conflicts_with => render_path(Prefix), - incoming_path => render_path(Full), - method => M, - existing_methods=> Ms - }}; - _ when M =:= <<"ALL">>, Ms =/= [] -> - {error, conflict, #{ - reason => duplicate_due_to_existing_methods, - at => Prefix, - existing => undefined, - incoming => undefined, - conflicts_with => render_path(Prefix), - incoming_path => render_path(Full), - method => M, - existing_methods=> Ms - }}; - _ -> - {ok, terminal_add(M, Payload, N0)} - end - end; +insert_route({Host, Path, Comparator, Payload, Opts}, Trie) when is_map(Opts) -> + insert(Host, Path, Comparator, Payload, Trie, Opts); +insert_route({Host, Path, Comparator, Payload}, Trie) -> + insert(Host, Path, Comparator, Payload, Trie); +insert_route({Path, Comparator, Payload}, Trie) -> + insert(Path, Comparator, Payload, Trie); +insert_route(Other, _Trie) -> + erlang:error({bad_route, Other}). -%% Branch when we insert a wildcard segment -safe_insert_segs(M, Payload, [K = {wild, NewVar} | Rest], N0, Prefix, Full) -> - Cs0 = maps:get(children, N0), - - %% 1) Wildcard overshadowing existing static terminal at same depth - case Rest of - [] -> - case find_static_overshadow_child(Cs0, M) of - {found, {ExistingKey, _ChildN, ExistingMs}} -> - {error, conflict, #{ - reason => overshadowing_route, - at => Prefix, - existing => ExistingKey, - incoming => K, - conflicts_with => render_path(Prefix ++ [ExistingKey]), - incoming_path => render_path(Full), - method => M, - existing_methods=> ExistingMs - }}; - none -> - wildcard_name_insert(M, Payload, NewVar, Rest, - N0, Prefix, Full, Cs0) - end; - _ -> - wildcard_name_insert(M, Payload, NewVar, Rest, - N0, Prefix, Full, Cs0) - end; +%%==================================================================== +%% Internal functions - matching +%%==================================================================== -%% Branch when we insert a static segment -safe_insert_segs(M, Payload, [K | Rest], N0, Prefix, Full) -> - Cs0 = maps:get(children, N0), - - %% Static overshadowing existing wildcard terminal at same depth - case Rest of - [] -> - case find_wild_child(Cs0) of - {wild, ExistingVar, ChildN} -> - ExistingMs = methods_list(ChildN), - case overshadow_methods(M, ExistingMs) of - true -> - {error, conflict, #{ - reason => overshadowing_route, - at => Prefix, - existing => {wild, ExistingVar}, - incoming => K, - conflicts_with => render_path(Prefix ++ [{wild, ExistingVar}]), - incoming_path => render_path(Full), - method => M, - existing_methods=> ExistingMs - }}; - false -> - static_continue(M, Payload, K, Rest, - N0, Prefix, Full, Cs0) - end; - none -> - static_continue(M, Payload, K, Rest, - N0, Prefix, Full, Cs0) - end; - _ -> - static_continue(M, Payload, K, Rest, - N0, Prefix, Full, Cs0) +host_trie(Host, #{hosts := Hosts}) -> + case maps:find(Host, Hosts) of + {ok, HostTrie} -> + {ok, HostTrie}; + error when Host =/= '_' -> + maps:find('_', Hosts); + error -> + error end. -%% Helper: continue wildcard insertion after overshadow/name checks -wildcard_name_insert(M, Payload, NewVar, Rest, N0, Prefix, Full, Cs0) -> - ExistingWild = find_wild_child(Cs0), - case ExistingWild of - none -> - Child0 = new_node(), - case safe_insert_segs(M, Payload, Rest, Child0, - Prefix ++ [{wild, NewVar}], Full) of - {ok, Child1} -> - {ok, N0#{children := maps:put({wild, NewVar}, Child1, Cs0)}}; - {error, conflict, Info} -> - {error, conflict, Info} - end; - {wild, ExistingVar, ChildN} -> - if ExistingVar =:= NewVar -> - case safe_insert_segs(M, Payload, Rest, ChildN, - Prefix ++ [{wild, NewVar}], Full) of - {ok, Child1} -> - {ok, N0#{children := maps:put({wild, ExistingVar}, Child1, Cs0)}}; - {error, conflict, Info} -> - {error, conflict, Info} - end; - true -> - {error, conflict, #{ - reason => wildcard_name_conflict, - at => Prefix, - existing => {wild, ExistingVar}, - incoming => {wild, NewVar}, - conflicts_with => render_path(Prefix ++ [{wild, ExistingVar}]), - incoming_path => render_path(Prefix ++ [{wild, NewVar}] ++ Rest), - method => M, - existing_methods=> methods_list(ChildN) - }} +%% A node only counts as a match when it actually carries a payload, so a +%% partially-matching literal branch falls back to a binding sibling rather +%% than dead-ending. +match([], Node, Bindings) -> + case is_terminal(Node) of + true -> {ok, Node, Bindings, []}; + false -> match_catch_all([], Node, Bindings) + end; +match([Segment | Rest], Node = #{children := Children}, Bindings) -> + case match_literal(Segment, Rest, Children, Bindings) of + {ok, _Node, _Bindings, _PathInfo} = Ok -> + Ok; + error -> + case match_bindings(Segment, Rest, Children, Bindings) of + {ok, _Node, _Bindings, _PathInfo} = Ok -> + Ok; + error -> + match_catch_all([Segment | Rest], Node, Bindings) end end. -%% Helper: continue static insertion after overshadow check -static_continue(M, Payload, K, Rest, N0, Prefix, Full, Cs0) -> - Child0 = maps:get(K, Cs0, new_node()), - case safe_insert_segs(M, Payload, Rest, Child0, - Prefix ++ [K], Full) of - {ok, Child1} -> - {ok, N0#{children := maps:put(K, Child1, Cs0)}}; - {error, conflict, Info} -> - {error, conflict, Info} +match_literal(Segment, Rest, Children, Bindings) -> + case maps:find(Segment, Children) of + {ok, Child} -> match(Rest, Child, Bindings); + error -> error end. -%%-------------------------------------------------------------------- -%% Matching (runtime lookup) -%%-------------------------------------------------------------------- +match_bindings(Segment, Rest, Children, Bindings) when is_binary(Segment) -> + Names = lists:sort([Name || {binding, Name} := _Child <- Children]), + try_bindings(Names, Segment, Rest, Children, Bindings); +match_bindings(_Segment, _Rest, _Children, _Bindings) -> + error. + +try_bindings([], _Segment, _Rest, _Children, _Bindings) -> + error; +try_bindings([Name | Tl], Segment, Rest, Children, Bindings) -> + Child = maps:get({binding, Name}, Children), + case match(Rest, Child, Bindings#{Name => Segment}) of + {ok, _Node, _Bindings, _PathInfo} = Ok -> + Ok; + error -> + try_bindings(Tl, Segment, Rest, Children, Bindings) + end. -do_match([], N, Binds) -> - {ok, N, Binds}; -do_match([Seg | Rest], N, Binds0) -> - Cs = maps:get(children, N), - - %% 1) Exact first - Exact = case maps:find(Seg, Cs) of - {ok, C} -> - case do_match(Rest, C, Binds0) of - error -> error; - Ok -> Ok - end; - error -> error - end, - case Exact of - {ok, _, _} -> Exact; +match_catch_all(Remaining, #{children := Children}, Bindings) -> + case maps:find('...', Children) of + {ok, Child} -> + case is_terminal(Child) of + true -> {ok, Child, Bindings, Remaining}; + false -> error + end; error -> - %% 2) Wildcard fallback - case find_wild_child(Cs) of - none -> error; - {wild, VarName, C0} -> - do_match(Rest, C0, Binds0#{ VarName => Seg }) - end + error end. -%%-------------------------------------------------------------------- -%% to_list helpers -%%-------------------------------------------------------------------- +is_terminal(#{terminal := Terminal}) -> + maps:size(Terminal) > 0. -gather(N, AccSegs, AccOut) -> - Ms = methods_list(N), - AccOut1 = - case Ms of - [] -> AccOut; - _ -> - Path = render_path(AccSegs), - MethodLines = - [<< (render_method(M))/binary, " ", Path/binary >> || M <- Ms], - MethodLines ++ AccOut - end, - Cs = maps:get(children, N), - maps:fold( - fun(K, Child, Out) -> - gather(Child, AccSegs ++ [K], Out) - end, AccOut1, Cs). +%% An exact comparator wins, then the catch-all '_'. Anything else is a +%% method-not-allowed, and the caller needs the list for the Allow header. +resolve(Comparator, Terminal) -> + case maps:find(Comparator, Terminal) of + {ok, Payload} -> + {ok, Payload}; + error -> + case maps:find('_', Terminal) of + {ok, Payload} -> {ok, Payload}; + error -> {error, comparator_not_found, lists:sort(maps:keys(Terminal))} + end + end. %%==================================================================== -%% EUnit tests +%% Internal functions - traversal %%==================================================================== --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -default_host_insert_and_lookup_test() -> - T0 = new(), - {ok, T1} = insert(<<"/users">>, get, payload1, T0), - - ?assert(member(get, <<"/users">>, T1)), - ?assertMatch({ok, _Node, payload1, #{}}, - lookup(get, <<"/users">>, T1)). - -binary_method_insert_and_lookup_test() -> - T0 = new(), - {ok, T1} = insert(<<"/users">>, <<"GET">>, payload1, T0), - - %% lookup using binary method - ?assertMatch({ok, _Node, payload1, #{}}, - lookup(<<"GET">>, <<"/users">>, T1)), - %% lookup using atom method - ?assertMatch({ok, _Node, payload1, #{}}, - lookup(get, <<"/users">>, T1)). - -wildcard_path_lookup_test() -> - T0 = new(), - {ok, T1} = insert('_', <<"/users/:id">>, get, payload1, T0, - #{strict => true}), - - ?assertMatch({ok, _Node, payload1, #{<<"id">> := <<"42">>}}, - lookup(get, <<"/users/42">>, T1)), - ?assertMatch({ok, _Node, payload1, #{<<"id">> := <<"abc">>}}, - lookup(get, <<"/users/abc">>, T1)). - -method_filtering_lookup_test() -> - T0 = new(), - {ok, T1} = insert(<<"/users">>, post, payload_post, T0), - - ?assertMatch({ok, _Node, payload_post, #{}}, - lookup(post, <<"/users">>, T1)), - ?assertEqual(error, - lookup(get, <<"/users">>, T1)). - -host_specific_only_test() -> - T0 = new(), - Host = <<"http://api.example.com">>, - - {ok, T1} = insert(Host, <<"/users">>, get, payload_host, T0), - - ?assertMatch({ok, _Node, payload_host, #{}}, - lookup(get, Host, <<"/users">>, T1)), - ?assertEqual(error, - lookup(get, - <<"http://other.example.com">>, - <<"/users">>, T1)). - -host_fallback_to_catchall_test() -> - T0 = new(), - %% insert only on '_' host - {ok, T1} = insert('_', <<"/users">>, get, payload_all, T0, #{}), - - %% should match when querying with another host due to fallback - ?assertMatch({ok, _Node, payload_all, #{}}, - lookup(get, - <<"http://api.example.com">>, - <<"/users">>, T1)). - -host_and_method_lookup_test() -> - T0 = new(), - Host = <<"http://api.example.com">>, - - {ok, T1} = insert(Host, <<"/users">>, post, payload_post, T0), - - ?assertMatch({ok, _Node, payload_post, #{}}, - lookup(post, Host, <<"/users">>, T1)), - ?assertEqual(error, - lookup(get, Host, <<"/users">>, T1)), - %% and also check that another host falls back to '_' only if '_' exists - ?assertEqual(error, - lookup(post, - <<"http://other.example.com">>, - <<"/users">>, T1)). - -strict_conflict_duplicate_pattern_test() -> - T0 = new(), - {ok, T1} = - insert('_', <<"/users/:id">>, get, payload1, T0, - #{strict => true}), - - {error, conflict, Conf} = - insert('_', <<"/users/:id">>, get, payload2, T1, - #{strict => true}), - - ?assertEqual(duplicate_pattern, maps:get(reason, Conf)), - ?assertEqual(<<"GET">>, maps:get(method, Conf)). - -overshadow_strict_conflict_static_then_wild_test() -> - T0 = new(), - {ok, T1} = insert('_', <<"/user/my_user">>, get, payload_static, T0, - #{strict => true}), - - {error, conflict, Conf} = - insert('_', <<"/user/:user_id">>, get, payload_wild, T1, - #{strict => true}), - - ?assertEqual(overshadowing_route, maps:get(reason, Conf)), - ?assertEqual(<<"GET">>, maps:get(method, Conf)), - ?assertEqual(<<"/user/my_user">>, maps:get(conflicts_with, Conf)), - ?assertEqual(<<"/user/:user_id">>, maps:get(incoming_path, Conf)). - -overshadow_strict_conflict_wild_then_static_test() -> - T0 = new(), - {ok, T1} = insert('_', <<"/user/:user_id">>, get, payload_wild, T0, - #{strict => true}), - - {error, conflict, Conf} = - insert('_', <<"/user/my_user">>, get, payload_static, T1, - #{strict => true}), - - ?assertEqual(overshadowing_route, maps:get(reason, Conf)), - ?assertEqual(<<"GET">>, maps:get(method, Conf)), - ?assertEqual(<<"/user/:user_id">>, maps:get(conflicts_with, Conf)), - ?assertEqual(<<"/user/my_user">>, maps:get(incoming_path, Conf)). - -overshadow_non_strict_warning_static_then_wild_test() -> - T0 = new(), - {ok, T1} = insert(<<"/user/my_user">>, get, payload_static, T0), - - %% This should only warn, not error - {ok, T2} = insert(<<"/user/:user_id">>, get, payload_wild, T1), - - %% /user/my_user should still match the static route - ?assertMatch({ok, _Node, payload_static, #{}}, - lookup(get, <<"/user/my_user">>, T2)), - %% and /user/other should match the wildcard route - ?assertMatch({ok, _Node, payload_wild, - #{<<"user_id">> := <<"other">>}}, - lookup(get, <<"/user/other">>, T2)). - -overshadow_non_strict_warning_wild_then_static_test() -> - T0 = new(), - {ok, T1} = insert(<<"/user/:user_id">>, get, payload_wild, T0), - - %% This should only warn, not error - {ok, T2} = insert(<<"/user/my_user">>, get, payload_static, T1), - - %% /user/my_user should match the static route - ?assertMatch({ok, _Node, payload_static, #{}}, - lookup(get, <<"/user/my_user">>, T2)), - %% and /user/other should match the wildcard route - ?assertMatch({ok, _Node, payload_wild, - #{<<"user_id">> := <<"other">>}}, - lookup(get, <<"/user/other">>, T2)). - -to_list_simple_test() -> - T0 = new(), - {ok, T1} = insert(<<"/users/:id">>, get, payload1, T0), - Lines = to_list(T1), - - %% We expect "GET /users/:id" in the list - ?assert(lists:member(<<"GET /users/:id">>, Lines)). - -lookup_returns_node_and_bindings_test() -> - T0 = new(), - {ok, T1} = insert(<<"localhost">>, <<"/user/:id">>, get, payload1, T0, - #{strict => true}), - {ok, Node, Payload, Binds} = lookup(get, <<"localhost">>, <<"/user/42">>, T1), - ?assert(is_map(Node)), - ?assertEqual(payload1, Payload), - ?assertMatch(#{<<"id">> := <<"42">>}, Binds). - -foldl_can_filter_routes_test() -> - T0 = new(), - {ok, T1} = insert(<<"/a">>, get, payload_a, T0), - {ok, T2} = insert(<<"/b">>, get, payload_b, T1), - - {ok, T3} = - foldl( - T2, - fun(Routes0) -> - [R || R = {_Host, Path, _M, _P} <- Routes0, - Path =/= <<"/b">>] - end - ), - - ?assertMatch({ok, _Node, payload_a, #{}}, - lookup(get, <<"/a">>, T3)), - ?assertEqual(error, - lookup(get, <<"/b">>, T3)). - - -foldl_can_rewrite_payloads_test() -> - T0 = new(), - {ok, T1} = insert(<<"/a">>, get, payload_a, T0), - {ok, T2} = insert(<<"/b">>, get, payload_b, T1), - - %% Byt payload för /a men låt /b vara oförändrad - {ok, T3} = - foldl( - T2, - fun(Routes0) -> - [case R of - {Host, <<"/a">>, <<"GET">>, payload_a} -> - {Host, <<"/a">>, <<"GET">>, payload_a_v2}; - _ -> - R - end || R <- Routes0] - end - ), - - ?assertMatch({ok, _NodeA, payload_a_v2, #{}}, - lookup(get, <<"/a">>, T3)), - ?assertMatch({ok, _NodeB, payload_b, #{}}, - lookup(get, <<"/b">>, T3)). - -foldl_can_rewrite_methods_test() -> - T0 = new(), - {ok, T1} = insert(<<"/a">>, get, payload_a, T0), - - %% Flytta routen från GET till POST (vi skickar 'post' som atom för att - %% samtidigt testa att from_list/insert normaliserar method korrekt) - {ok, T2} = - foldl( - T1, - fun(Routes0) -> - [case R of - {Host, <<"/a">>, <<"GET">>, Payload} -> - {Host, <<"/a">>, post, Payload}; - _ -> - R - end || R <- Routes0] - end - ), - - %% GET ska inte längre matcha - ?assertEqual(error, - lookup(get, <<"/a">>, T2)), - %% POST ska matcha och ge samma payload - ?assertMatch({ok, _Node, payload_a, #{}}, - lookup(post, <<"/a">>, T2)). - --endif. + +gather_routes([], Acc) -> + Acc; +gather_routes([{Host, HostTrie} | Tl], Acc) -> + gather_routes(Tl, gather_node(HostTrie, [], Host, Acc)). + +gather_node(Node = #{children := Children}, Segments, Host, Acc) -> + Path = render_path(Segments), + Acc0 = [{Host, Path, Comparator, Payload} + || Comparator := Payload <- maps:get(terminal, Node)] ++ Acc, + gather_children(maps:to_list(Children), Segments, Host, Acc0). + +gather_children([], _Segments, _Host, Acc) -> + Acc; +gather_children([{Key, Child} | Tl], Segments, Host, Acc) -> + gather_children(Tl, Segments, Host, gather_node(Child, Segments ++ [Key], Host, Acc)). + +render_route(Comparator, Path) when is_integer(Path) -> + <<(render_comparator(Comparator))/binary, " ", (integer_to_binary(Path))/binary>>; +render_route(Comparator, Path) -> + <<(render_comparator(Comparator))/binary, " ", Path/binary>>. + +render_comparator('_') -> <<"'_'">>; +render_comparator(C) -> C. + +render_path([StatusCode]) when is_integer(StatusCode) -> + StatusCode; +render_path([?ROOT | Rest]) -> + <<"/", (join([render_key(Key) || Key <- Rest], <<"/">>))/binary>>; +render_path(Segments) -> + join([render_key(Key) || Key <- Segments], <<"/">>). + +render_key({binding, Name}) -> <<":", Name/binary>>; +render_key('...') -> <<"[...]">>; +render_key(Key) when is_binary(Key) -> Key; +render_key(Key) when is_integer(Key) -> integer_to_binary(Key). + +join([], _Sep) -> + <<>>; +join([Bin], _Sep) -> + Bin; +join([Bin | Tl], Sep) -> + <>. diff --git a/test/nova_routing_trie_tests.erl b/test/nova_routing_trie_tests.erl new file mode 100644 index 0000000..212ba66 --- /dev/null +++ b/test/nova_routing_trie_tests.erl @@ -0,0 +1,400 @@ +%%% Parity suite for nova_routing_trie. +%%% +%%% The first half of this file is a port of routing_tree's own EUnit suite, +%%% which is the specification of the behaviour nova_routing_trie replaces. +%%% The second half covers behaviour that is new or deliberately different. +-module(nova_routing_trie_tests). + +-include_lib("eunit/include/eunit.hrl"). + +-import(nova_routing_trie, [new/0, new/1, insert/4, insert/5, insert/6, find/4, + member/3, member/4, routes/1, to_list/1, from_list/1, + foldl/2]). + +%% Insert that asserts success, to keep the tests readable. +ins(Host, Path, Comparator, Payload, Trie) -> + {ok, Trie0} = insert(Host, Path, Comparator, Payload, Trie), + Trie0. + +%%==================================================================== +%% Ported from routing_tree - basic insert and lookup +%%==================================================================== + +simple_string_lookup_test() -> + T = ins('_', "/my/route", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"/my/route">>, "GET", T)). + +simple_binary_lookup_test() -> + T = ins('_', <<"/my/route">>, <<"GET">>, "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"/my/route">>, <<"GET">>, T)). + +list_of_segments_lookup_test() -> + T = ins('_', "/my/route", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, [<<"my">>, <<"route">>], "GET", T)). + +root_path_lookup_test() -> + T = ins('_', "/", "GET", "ROOT", new()), + ?assertEqual({ok, #{}, "ROOT"}, find('_', <<"/">>, "GET", T)). + +bindings_lookup_test() -> + T = ins('_', "/my/:route", "GET", "ONE", new()), + ?assertEqual({ok, #{<<"route">> => <<"monkey">>}, "ONE"}, + find(<<"my_host">>, <<"/my/monkey">>, "GET", T)). + +complex_lookup_test() -> + T0 = ins('_', "/my/:route", "GET", "ONE", new()), + T1 = ins('_', "/my/inbox/:message", "POST", "TWO", T0), + T2 = ins('_', "/my/inbox/:message", "GET", "THREE", T1), + T3 = ins('_', "/my/inbox", "GET", "FOUR", T2), + T4 = ins('_', "/", "GET", "FIVE", T3), + + ?assertEqual({ok, #{<<"message">> => <<"hello">>}, "THREE"}, + find(<<"my_host">>, <<"/my/inbox/hello">>, "GET", T4)), + ?assertEqual({ok, #{}, "FOUR"}, find(<<"my_host">>, <<"/my/inbox">>, "GET", T4)), + ?assertEqual({ok, #{}, "FIVE"}, find(<<"my_host">>, <<"/">>, "GET", T4)). + +any_comparator_matches_concrete_method_test() -> + T = ins('_', "/my/route", '_', "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"/my/route">>, "PUT", T)), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"/my/route">>, <<"DELETE">>, T)). + +dash_in_path_test() -> + T = ins('_', "/my-test-route", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"/my-test-route">>, "GET", T)). + +trailing_slash_test() -> + T = ins('_', "/my_app/", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"/my_app">>, "GET", T)), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"/my_app/">>, "GET", T)). + +double_slash_test() -> + T = ins('_', "/my/route", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"//my//route">>, "GET", T)). + +not_found_test() -> + T = ins('_', "/my/route", "GET", "ONE", new()), + ?assertEqual({error, not_found}, find(<<"my_host">>, <<"/nope">>, "GET", T)), + ?assertEqual({error, not_found}, find(<<"my_host">>, <<"/my">>, "GET", T)). + +%%==================================================================== +%% Ported from routing_tree - the [...] catch-all +%%==================================================================== + +catch_all_with_trailing_segments_test() -> + T = ins('_', "/my/route/[...]", '_', "ONE", new()), + ?assertEqual({ok, #{}, "ONE", [<<"is">>, <<"amazing">>]}, + find(<<"my_host">>, <<"/my/route/is/amazing">>, "PUT", T)). + +catch_all_with_one_trailing_segment_test() -> + T = ins('_', "/my/assets/[...]", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE", [<<"logo.png">>]}, + find(<<"my_host">>, <<"/my/assets/logo.png">>, "GET", T)). + +%% routing_tree returned the 3-tuple when the catch-all consumed nothing. +catch_all_with_no_trailing_segments_test() -> + T = ins('_', "/my/assets/[...]", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find(<<"my_host">>, <<"/my/assets">>, "GET", T)). + +catch_all_not_last_in_path_test() -> + ?assertThrow({bad_routingfile, wildcard_not_last_in_path}, + insert('_', "/my/assets/[...]/not/working", "GET", "ONE", new())). + +%% A literal route below the same prefix still wins over the catch-all. +catch_all_does_not_shadow_literal_test() -> + T0 = ins('_', "/assets/[...]", "GET", "STATIC", new()), + T1 = ins('_', "/assets/manifest.json", "GET", "MANIFEST", T0), + ?assertEqual({ok, #{}, "MANIFEST"}, find('_', <<"/assets/manifest.json">>, "GET", T1)), + ?assertEqual({ok, #{}, "STATIC", [<<"img">>, <<"logo.png">>]}, + find('_', <<"/assets/img/logo.png">>, "GET", T1)). + +%%==================================================================== +%% Ported from routing_tree - status codes +%%==================================================================== + +status_code_insert_and_lookup_test() -> + T = ins('_', 404, '_', "NOT_FOUND", new()), + ?assertEqual({ok, #{}, "NOT_FOUND"}, find('_', 404, '_', T)), + ?assertEqual({error, not_found}, find('_', 500, '_', T)). + +status_codes_do_not_collide_with_paths_test() -> + T0 = ins('_', 404, '_', "STATUS", new()), + T1 = ins('_', "/404", "GET", "PATH", T0), + ?assertEqual({ok, #{}, "STATUS"}, find('_', 404, '_', T1)), + ?assertEqual({ok, #{}, "PATH"}, find('_', <<"/404">>, "GET", T1)). + +%%==================================================================== +%% Ported from routing_tree - hosts +%%==================================================================== + +host_specific_route_test() -> + Host = <<"api.example.com">>, + T = ins(Host, "/users", "GET", "HOST", new()), + ?assertEqual({ok, #{}, "HOST"}, find(Host, <<"/users">>, "GET", T)), + ?assertEqual({error, not_found}, find(<<"other.example.com">>, <<"/users">>, "GET", T)). + +host_falls_back_to_catchall_host_test() -> + T = ins('_', "/users", "GET", "ANY", new()), + ?assertEqual({ok, #{}, "ANY"}, find(<<"api.example.com">>, <<"/users">>, "GET", T)). + +%% A host-specific tree is used on its own; there is no cascade into '_'. +%% This mirrors routing_tree and is relied on by host-scoped routers. +host_specific_tree_does_not_cascade_test() -> + Host = <<"api.example.com">>, + T0 = ins('_', "/shared", "GET", "ANY", new()), + T1 = ins(Host, "/only-here", "GET", "HOST", T0), + ?assertEqual({ok, #{}, "HOST"}, find(Host, <<"/only-here">>, "GET", T1)), + ?assertEqual({error, not_found}, find(Host, <<"/shared">>, "GET", T1)). + +%%==================================================================== +%% Ported from routing_tree - duplicates and strict mode +%%==================================================================== + +%% routing_tree kept the first insert and ignored the second. +duplicate_route_keeps_first_test() -> + T0 = ins('_', "/profile", "GET", "ONE", new()), + T1 = ins('_', "/profile", "GET", "TWO", T0), + ?assertEqual({ok, #{}, "ONE"}, find('_', <<"/profile">>, "GET", T1)). + +duplicate_route_overwrite_option_test() -> + T0 = ins('_', "/profile", "GET", "ONE", new(#{on_duplicate => overwrite})), + T1 = ins('_', "/profile", "GET", "TWO", T0), + ?assertEqual({ok, #{}, "TWO"}, find('_', <<"/profile">>, "GET", T1)). + +strict_duplicate_route_errors_test() -> + T0 = ins('_', "/profile", "GET", "ONE", new(#{strict => true})), + {error, conflict, Conflict} = insert('_', "/profile", "GET", "TWO", T0), + ?assertEqual(duplicate_pattern, maps:get(reason, Conflict)), + ?assertEqual(<<"GET">>, maps:get(comparator, Conflict)), + ?assertEqual(<<"/profile">>, maps:get(incoming_path, Conflict)). + +%% use_strict is accepted as an alias, so a routing_tree options map works. +strict_accepts_use_strict_alias_test() -> + T0 = ins('_', "/profile", "GET", "ONE", new(#{use_strict => true, convert_to_binary => true})), + ?assertMatch({error, conflict, _}, insert('_', "/profile", "GET", "TWO", T0)). + +strict_literal_then_binding_errors_test() -> + T0 = ins('_', "/user/my_user", "GET", "STATIC", new(#{strict => true})), + {error, conflict, Conflict} = insert('_', "/user/:user_id", "GET", "BINDING", T0), + ?assertEqual(overshadowing_route, maps:get(reason, Conflict)), + ?assertEqual(<<"/user/my_user">>, maps:get(conflicts_with, Conflict)), + ?assertEqual(<<"/user/:user_id">>, maps:get(incoming_path, Conflict)). + +strict_binding_then_literal_errors_test() -> + T0 = ins('_', "/user/:user_id", "GET", "BINDING", new(#{strict => true})), + {error, conflict, Conflict} = insert('_', "/user/my_user", "GET", "STATIC", T0), + ?assertEqual(overshadowing_route, maps:get(reason, Conflict)), + ?assertEqual(<<"/user/:user_id">>, maps:get(conflicts_with, Conflict)), + ?assertEqual(<<"/user/my_user">>, maps:get(incoming_path, Conflict)). + +strict_conflicting_binding_names_error_test() -> + T0 = ins('_', "/user/:id", "GET", "ONE", new(#{strict => true})), + {error, conflict, Conflict} = insert('_', "/user/:user_id", "POST", "TWO", T0), + ?assertEqual(binding_name_conflict, maps:get(reason, Conflict)). + +%% Overlapping literal and binding routes are ordinary REST, so they must not +%% error - or even warn - outside strict mode. +non_strict_literal_and_binding_coexist_test() -> + T0 = ins('_', "/users/new", "GET", "NEW", new()), + T1 = ins('_', "/users/:id", "GET", "SHOW", T0), + ?assertEqual({ok, #{}, "NEW"}, find('_', <<"/users/new">>, "GET", T1)), + ?assertEqual({ok, #{<<"id">> => <<"42">>}, "SHOW"}, find('_', <<"/users/42">>, "GET", T1)). + +non_strict_binding_then_literal_coexist_test() -> + T0 = ins('_', "/users/:id", "GET", "SHOW", new()), + T1 = ins('_', "/users/new", "GET", "NEW", T0), + ?assertEqual({ok, #{}, "NEW"}, find('_', <<"/users/new">>, "GET", T1)), + ?assertEqual({ok, #{<<"id">> => <<"42">>}, "SHOW"}, find('_', <<"/users/42">>, "GET", T1)). + +%%==================================================================== +%% Method resolution and 405 +%%==================================================================== + +method_specific_routes_test() -> + T0 = ins('_', "/users", "GET", "LIST", new()), + T1 = ins('_', "/users", "POST", "CREATE", T0), + ?assertEqual({ok, #{}, "LIST"}, find('_', <<"/users">>, "GET", T1)), + ?assertEqual({ok, #{}, "CREATE"}, find('_', <<"/users">>, "POST", T1)). + +method_not_allowed_test() -> + T0 = ins('_', "/users", "GET", "LIST", new()), + T1 = ins('_', "/users", "PUT", "REPLACE", T0), + ?assertEqual({error, comparator_not_found, [<<"GET">>, <<"PUT">>]}, + find('_', <<"/users">>, "DELETE", T1)). + +%% A path that exists at all is a 405, not a 404 - that distinction is the +%% whole reason find/4 has two error shapes. +method_not_allowed_is_not_not_found_test() -> + T = ins('_', "/users", "GET", "LIST", new()), + ?assertMatch({error, comparator_not_found, _}, find('_', <<"/users">>, "POST", T)), + ?assertEqual({error, not_found}, find('_', <<"/nope">>, "POST", T)). + +%% '_' added after a concrete method: both are kept, and the concrete method +%% still wins for its own verb. +exact_method_beats_any_test() -> + T0 = ins('_', "/users", "GET", "SPECIFIC", new()), + T1 = ins('_', "/users", '_', "ANY", T0), + ?assertEqual({ok, #{}, "SPECIFIC"}, find('_', <<"/users">>, "GET", T1)), + ?assertEqual({ok, #{}, "ANY"}, find('_', <<"/users">>, "POST", T1)). + +%% The reverse order is a duplicate, because '_' already answers GET. Like +%% routing_tree, the first route registered wins. +concrete_method_after_any_is_a_duplicate_test() -> + T0 = ins('_', "/users", '_', "ANY", new()), + T1 = ins('_', "/users", "GET", "SPECIFIC", T0), + ?assertEqual({ok, #{}, "ANY"}, find('_', <<"/users">>, "GET", T1)), + ?assertEqual({ok, #{}, "ANY"}, find('_', <<"/users">>, "POST", T1)). + +lowercase_method_is_normalised_test() -> + T = ins('_', "/users", get, "LIST", new()), + ?assertEqual({ok, #{}, "LIST"}, find('_', <<"/users">>, <<"GET">>, T)), + ?assertEqual({ok, #{}, "LIST"}, find('_', <<"/users">>, "get", T)). + +%%==================================================================== +%% Matching improvements over routing_tree +%%==================================================================== + +%% routing_tree committed to the first matching sibling and never backtracked, +%% so this returned not_found. +backtracks_from_literal_to_binding_test() -> + T0 = ins('_', "/a/b/d", "GET", "LITERAL", new()), + T1 = ins('_', "/a/:x/c", "GET", "BINDING", T0), + ?assertEqual({ok, #{<<"x">> => <<"b">>}, "BINDING"}, find('_', <<"/a/b/c">>, "GET", T1)). + +%% A literal branch that exists but carries no payload must not dead-end. +backtracks_past_non_terminal_literal_test() -> + T0 = ins('_', "/a/b/c", "GET", "DEEP", new()), + T1 = ins('_', "/a/:x", "GET", "BINDING", T0), + ?assertEqual({ok, #{<<"x">> => <<"b">>}, "BINDING"}, find('_', <<"/a/b">>, "GET", T1)). + +%% routing_tree kept whichever binding it happened to visit first, leaving the +%% other permanently unreachable. +multiple_binding_siblings_are_all_reachable_test() -> + T0 = ins('_', "/p/:id/picture", "GET", "PICTURE", new()), + T1 = ins('_', "/p/:user_id/name", "GET", "NAME", T0), + ?assertEqual({ok, #{<<"id">> => <<"7">>}, "PICTURE"}, find('_', <<"/p/7/picture">>, "GET", T1)), + ?assertEqual({ok, #{<<"user_id">> => <<"7">>}, "NAME"}, find('_', <<"/p/7/name">>, "GET", T1)). + +falls_back_to_catch_all_when_binding_dead_ends_test() -> + T0 = ins('_', "/a/:x/c", "GET", "BINDING", new()), + T1 = ins('_', "/a/[...]", "GET", "CATCHALL", T0), + ?assertEqual({ok, #{<<"x">> => <<"b">>}, "BINDING"}, find('_', <<"/a/b/c">>, "GET", T1)), + ?assertEqual({ok, #{}, "CATCHALL", [<<"b">>, <<"z">>]}, find('_', <<"/a/b/z">>, "GET", T1)). + +%%==================================================================== +%% Path canonicalisation +%%==================================================================== + +dot_segments_are_resolved_test() -> + T = ins('_', "/a/b", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find('_', <<"/a/./b">>, "GET", T)), + ?assertEqual({ok, #{}, "ONE"}, find('_', <<"/a/c/../b">>, "GET", T)). + +%% Traversal above the root is clamped rather than escaping it. +dotdot_is_clamped_at_root_test() -> + T = ins('_', "/a", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find('_', <<"/../a">>, "GET", T)), + ?assertEqual({ok, #{}, "ONE"}, find('_', <<"/../../a">>, "GET", T)), + ?assertEqual({error, not_found}, find('_', <<"/..">>, "GET", T)). + +query_string_is_ignored_test() -> + T = ins('_', "/search", "GET", "ONE", new()), + ?assertEqual({ok, #{}, "ONE"}, find('_', <<"/search?q=erlang">>, "GET", T)), + ?assertEqual({ok, #{}, "ONE"}, find('_', <<"/search#frag">>, "GET", T)). + +%%==================================================================== +%% member/3,4 +%%==================================================================== + +member_test() -> + T = ins('_', "/users/:id", "GET", "ONE", new()), + ?assert(member('_', <<"/users/42">>, "GET", T)), + ?assertNot(member('_', <<"/users/42">>, "POST", T)), + ?assertNot(member('_', <<"/nope">>, "GET", T)). + +%%==================================================================== +%% routes/1, to_list/1, from_list/1, foldl/2 +%%==================================================================== + +routes_round_trip_test() -> + T0 = ins('_', "/a", "GET", payload_a, new()), + T1 = ins('_', "/b/:id", "POST", payload_b, T0), + T2 = ins('_', "/assets/[...]", '_', payload_c, T1), + T3 = ins('_', 404, '_', payload_d, T2), + + {ok, Rebuilt} = from_list(routes(T3)), + + ?assertEqual({ok, #{}, payload_a}, find('_', <<"/a">>, "GET", Rebuilt)), + ?assertEqual({ok, #{<<"id">> => <<"1">>}, payload_b}, find('_', <<"/b/1">>, "POST", Rebuilt)), + ?assertEqual({ok, #{}, payload_c, [<<"x">>]}, find('_', <<"/assets/x">>, "GET", Rebuilt)), + ?assertEqual({ok, #{}, payload_d}, find('_', 404, '_', Rebuilt)). + +%% Map payloads must survive the round trip; an earlier clause ordering +%% reinterpreted them as per-insert options. +routes_round_trip_with_map_payload_test() -> + T = ins('_', "/a", "GET", #{app => my_app}, new()), + {ok, Rebuilt} = from_list(routes(T)), + ?assertEqual({ok, #{}, #{app => my_app}}, find('_', <<"/a">>, "GET", Rebuilt)). + +routes_preserves_host_test() -> + T0 = ins(<<"api.example.com">>, "/a", "GET", host_payload, new()), + T1 = ins('_', "/a", "GET", any_payload, T0), + {ok, Rebuilt} = from_list(routes(T1)), + ?assertEqual({ok, #{}, host_payload}, find(<<"api.example.com">>, <<"/a">>, "GET", Rebuilt)), + ?assertEqual({ok, #{}, any_payload}, find(<<"other.com">>, <<"/a">>, "GET", Rebuilt)). + +to_list_test() -> + T0 = ins('_', "/users/:id", "GET", payload, new()), + T1 = ins('_', "/assets/[...]", '_', payload, T0), + Lines = to_list(T1), + ?assert(lists:member(<<"GET /users/:id">>, Lines)), + ?assert(lists:member(<<"'_' /assets/[...]">>, Lines)). + +foldl_can_filter_routes_test() -> + T0 = ins('_', "/a", "GET", payload_a, new()), + T1 = ins('_', "/b", "GET", payload_b, T0), + {ok, T2} = foldl(T1, fun(Routes) -> + [R || R = {_Host, Path, _C, _P} <- Routes, Path =/= <<"/b">>] + end), + ?assertEqual({ok, #{}, payload_a}, find('_', <<"/a">>, "GET", T2)), + ?assertEqual({error, not_found}, find('_', <<"/b">>, "GET", T2)). + +foldl_can_rewrite_payloads_test() -> + T0 = ins('_', "/a", "GET", payload_a, new()), + T1 = ins('_', "/b", "GET", payload_b, T0), + {ok, T2} = foldl(T1, fun(Routes) -> + [case R of + {Host, <<"/a">>, C, payload_a} -> {Host, <<"/a">>, C, payload_a_v2}; + _ -> R + end || R <- Routes] + end), + ?assertEqual({ok, #{}, payload_a_v2}, find('_', <<"/a">>, "GET", T2)), + ?assertEqual({ok, #{}, payload_b}, find('_', <<"/b">>, "GET", T2)). + +foldl_can_rewrite_methods_test() -> + T0 = ins('_', "/a", "GET", payload_a, new()), + {ok, T1} = foldl(T0, fun(Routes) -> + [{Host, Path, post, P} || {Host, Path, _C, P} <- Routes] + end), + ?assertEqual({error, comparator_not_found, [<<"POST">>]}, find('_', <<"/a">>, "GET", T1)), + ?assertEqual({ok, #{}, payload_a}, find('_', <<"/a">>, "POST", T1)). + +foldl_preserves_options_test() -> + T0 = ins('_', "/a", "GET", payload_a, new(#{strict => true})), + {ok, T1} = foldl(T0, fun(Routes) -> Routes end), + ?assertMatch({error, conflict, _}, insert('_', "/a", "GET", payload_b, T1)). + +foldl_badreturn_test() -> + T = ins('_', "/a", "GET", payload_a, new()), + ?assertError({badreturn, _}, foldl(T, fun(_Routes) -> not_a_list end)). + +from_list_bad_route_test() -> + ?assertError({bad_route, _}, from_list([{"/a"}])). + +%%==================================================================== +%% Empty trie +%%==================================================================== + +empty_trie_test() -> + ?assertEqual({error, not_found}, find('_', <<"/anything">>, "GET", new())), + ?assertEqual([], routes(new())), + ?assertEqual([], to_list(new())). From b50867990d3798d7cff231c7f9f1cae3dccc6d26 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 10 Aug 2026 11:44:14 +0200 Subject: [PATCH 16/21] fix(router): drive the trie correctly and repair route compilation nova_router was still written against routing_tree with the module name swapped, so nothing it did reached the new trie intact. Contract: - Both find/4 call sites (execute/2 and render_status_page/5) now call a function that exists. nova_routing_trie:find/4 was never implemented. - insert/6 handles {ok, Trie} | {error, conflict, Conflict} instead of storing the return value as the dispatch table. Previously the first insert stored {ok, Trie} and the second badmatched, so any application with two routes failed to compile. - lookup_url/4 passes arguments in the order the callee declares them, and lookup_url/1,2,3 keep their existing return shapes so nova_handler and downstream applications are unaffected. - compile/1 no longer double-wraps the trie options, so use_strict_routing reaches the trie for the first time. Plugins: - plugin_strategy deduped with lists:ukeysort(1, ...) on {Type, Module, Opts} tuples, which keys on the phase. At most one pre_request and one post_request plugin survived per route, including on the default path where the route declares no local plugins at all - a stock configuration silently lost JSON body decoding, CORS and correlation IDs. Dedupe is now on {Type, Module} and preserves order. - The default strategy is local_or_global, which is how Nova has always behaved. An unknown strategy logs and falls back rather than crashing the boot. Security: - override_secure accepted only a fun, so override_secure => true - the obvious reading of the name - crashed compilation with a case_clause. It now takes the same values as secure, and true is rejected with a log rather than a crash. Runtime route changes: - remove_application/1 filtered on a 3-tuple while the trie emits 4-tuples, so it matched nothing and wiped the routing table for every application. It now matches the real shape, keeps #cowboy_handler_value{} websocket routes in scope, honours dispatch_backend rather than hardcoding persistent_term, and drops the application from compiled_apps/0 and the nova env. - add_routes/1 resolves the router the same way compile/3 does, so router_module and the Elixir App.Router convention are honoured, and it goes through get_routes/2 so controller-declared routes are picked up. - add_routes/2 accepts a bare map and a flat list of route maps, not just a list of lists, and inserts with on_duplicate => overwrite so it behaves as the routing guide describes. Also: compile/3 no longer lets one application's options leak into the next one compiled, and no longer grows compiled_apps/0 and the nova apps env without bound on every recompile. Dropped the dead canonicalise/2. The Allow header is built with lists:join rather than percent-decoding method tokens. --- src/nova_router.erl | 296 +++++++++++++++++++++++--------------- src/nova_routing_trie.erl | 2 +- 2 files changed, 185 insertions(+), 113 deletions(-) diff --git a/src/nova_router.erl b/src/nova_router.erl index e8c2558..b3bb82f 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -41,7 +41,13 @@ -include("../include/nova.hrl"). -type bindings() :: #{binary() := binary()}. --export_type([bindings/0]). + +-type lookup_result() :: {ok, bindings(), Value :: term()} | + {ok, bindings(), Value :: term(), PathInfo :: [binary()]} | + {error, not_found} | + {error, comparator_not_found, AllowedMethods :: [binary()]}. + +-export_type([bindings/0, lookup_result/0]). %% This module is also exposing callbacks for routers -callback routes(Env :: atom()) -> Routes :: [map()]. @@ -70,7 +76,7 @@ compile(Apps) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), StoredDispatch = StorageBackend:get(nova_dispatch, - nova_routing_trie:new(#{options => #{strict => UseStrict}})), + nova_routing_trie:new(#{strict => UseStrict})), Dispatch = compile(Apps, StoredDispatch, #{}), %% Write the updated dispatch to storage StorageBackend:put(nova_dispatch, Dispatch), @@ -88,8 +94,7 @@ execute(Req = #{host := Host, path := Path, method := Method}, Env) -> render_status_page('_', 404, #{error => "Not found in path"}, Req, Env); {error, comparator_not_found, AllowedMethods} -> logger:debug(<<"Method not allowed: ~p for ~p. Allowed methods: ~p">>, [Method, Path, AllowedMethods]), - %% Join the elements in AllowedMethods with a colon - AllowHeader = iolist_to_binary(string:join([unicode:characters_to_list(uri_string:unquote(M)) || M <- AllowedMethods], ", ")), + AllowHeader = iolist_to_binary(lists:join(<<", ">>, AllowedMethods)), %% Set the 'allow'-header Req1 = cowboy_req:set_resp_header(<<"allow">>, AllowHeader, Req), render_status_page('_', 405, #{error => "Method not allowed"}, Req1, Env); @@ -129,24 +134,31 @@ execute(Req = #{host := Host, path := Path, method := Method}, Env) -> } }; Error -> - ?LOG_ERROR(#{reason => <<"Unexpected return from nova_routing_trie:lookup/4">>, + ?LOG_ERROR(#{reason => <<"Unexpected return from nova_routing_trie:find/4">>, return_object => Error}), render_status_page(Host, 404, #{error => Error}, Req, Env) end. +-spec lookup_url(Path :: nova_routing_trie:path()) -> lookup_result(). lookup_url(Path) -> lookup_url('_', Path). +-spec lookup_url(Host :: binary() | atom(), Path :: nova_routing_trie:path()) -> lookup_result(). lookup_url(Host, Path) -> lookup_url(Host, Path, '_'). +-spec lookup_url(Host :: binary() | atom(), Path :: nova_routing_trie:path(), + Method :: nova_routing_trie:comparator()) -> lookup_result(). lookup_url(Host, Path, Method) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), Dispatch = StorageBackend:get(nova_dispatch), lookup_url(Host, Path, Method, Dispatch). +-spec lookup_url(Host :: binary() | atom(), Path :: nova_routing_trie:path(), + Method :: nova_routing_trie:comparator(), + Dispatch :: nova_routing_trie:trie()) -> lookup_result(). lookup_url(Host, Path, Method, Dispatch) -> - nova_routing_trie:lookup(Host, Path, Method, Dispatch). + nova_routing_trie:find(Host, Path, Method, Dispatch). %%-------------------------------------------------------------------- @@ -160,11 +172,8 @@ lookup_url(Host, Path, Method, Dispatch) -> %%-------------------------------------------------------------------- -spec add_routes(App :: atom()) -> ok. add_routes(App) -> - Router = erlang:list_to_atom(io_lib:format("~s_router", [App])), Env = nova:get_environment(), - %% Call the router - Routes = Router:routes(Env), - add_routes(App, Routes). + add_routes(App, get_routes(router_module(App), Env)). %%-------------------------------------------------------------------- %% @doc @@ -174,9 +183,22 @@ add_routes(App) -> %% @end %%-------------------------------------------------------------------- -spec add_routes(App :: atom(), Routes :: [map()] | map()) -> ok. -add_routes(_App, []) -> ok; +add_routes(_App, []) -> + ok; +add_routes(App, Routes) when is_map(Routes) -> + add_routes(App, [Routes]); add_routes(App, [Routes|Tl]) when is_list(Routes) -> - Options = #{}, + %% A list of route-lists, as produced by a router that returns several + %% groups. Each group is compiled on its own. + ok = insert_route_maps(App, Routes), + add_routes(App, Tl); +add_routes(App, [RouteInfo|_Tl] = Routes) when is_map(RouteInfo) -> + insert_route_maps(App, Routes); +add_routes(App, Routes) -> + ?LOG_ERROR(#{reason => <<"Invalid routes structure">>, app => App, routes => Routes}), + throw({error, {invalid_routes, App, Routes}}). + +insert_route_maps(App, Routes) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), Dispatch = StorageBackend:get(nova_dispatch), @@ -184,23 +206,20 @@ add_routes(App, [Routes|Tl]) when is_list(Routes) -> CompiledApps = StorageBackend:get(?NOVA_APPS, []), CompiledApps0 = case lists:keyfind(App, 1, CompiledApps) of - false -> - [{App, maps:get(prefix, Options, "/")}|CompiledApps]; - _StoredApp -> - CompiledApps + false -> [{App, "/"}|CompiledApps]; + _StoredApp -> CompiledApps end, - Options1 = Options#{app => App, router_file => undefined}, + %% Routes added at runtime replace any route already registered on the + %% same path and method, which is what the routing guide promises. + Options = #{app => App, router_file => undefined, + insert_opts => #{on_duplicate => overwrite}}, - {ok, Dispatch1, _Options2} = compile_paths(Routes, Dispatch, Options1), + {ok, Dispatch1, _Options0} = compile_paths(Routes, Dispatch, Options), StorageBackend:put(?NOVA_APPS, CompiledApps0), StorageBackend:put(nova_dispatch, Dispatch1), - - add_routes(App, Tl); -add_routes(App, Routes) -> - ?LOG_ERROR(#{reason => <<"Invalid routes structure">>, app => App, routes => Routes}), - throw({error, {invalid_routes, App, Routes}}). + ok. %%-------------------------------------------------------------------- @@ -210,17 +229,24 @@ add_routes(App, Routes) -> %%-------------------------------------------------------------------- -spec remove_application(Application :: atom()) -> ok. remove_application(Application) when is_atom(Application) -> - Dispatch = persistent_term:get(nova_dispatch), - %% Remove all routes for this application + StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + Dispatch = StorageBackend:get(nova_dispatch), {ok, Dispatch0} = nova_routing_trie:foldl(Dispatch, - fun(R) -> - [ X || X = {_Host, _Prefix, #nova_handler_value{app = App}} <- R, - App =/= Application ] + fun(Routes) -> + [Route || Route <- Routes, route_app(Route) =/= Application] end), - persistent_term:put(nova_dispatch, Dispatch0), + StorageBackend:put(nova_dispatch, Dispatch0), + StorageBackend:put(?NOVA_APPS, lists:keydelete(Application, 1, StorageBackend:get(?NOVA_APPS, []))), + nova:set_env(apps, lists:keydelete(Application, 1, nova:get_env(apps, []))), ok. +%% Both handler kinds carry the owning application, and dropping the cowboy +%% one would silently strip every websocket route. +route_app({_Host, _Path, _Method, #nova_handler_value{app = App}}) -> App; +route_app({_Host, _Path, _Method, #cowboy_handler_value{app = App}}) -> App; +route_app(_Route) -> undefined. + %%%%%%%%%%%%%%%%%%%%%%%% %% INTERNAL FUNCTIONS %% @@ -248,26 +274,13 @@ apply_callback(Module, Function, Args) -> -spec compile(Apps :: [atom() | {atom(), map()}], Dispatch :: nova_routing_trie:trie(), Options :: map()) -> nova_routing_trie:trie(). compile([], Dispatch, _Options) -> Dispatch; -compile([{App, Options}|Tl], Dispatch, GlobalOptions) -> - compile([App|Tl], Dispatch, maps:merge(Options, GlobalOptions)); +compile([{App, AppOptions}|Tl], Dispatch, GlobalOptions) -> + %% Per-application options win over the global ones, and must not leak + %% into the applications compiled after this one. + Dispatch0 = compile([App], Dispatch, maps:merge(GlobalOptions, AppOptions)), + compile(Tl, Dispatch0, GlobalOptions); compile([App|Tl], Dispatch, Options) -> - %% Fetch the router-module for this application - Router = - %% The router can be explicitly defined in the application environment, - %% if not we will try to detect it based on the language used in the project - case application:get_env(App, router_module) of - {ok, RouterModule} -> - RouterModule; - undefined -> - case nova:detect_language() of - elixir -> - %% We build the router as App.Router - erlang:list_to_atom(io_lib:format("~s.Router", [App])); - _ -> - %% All other languages are using the app_router convention - erlang:list_to_atom(io_lib:format("~s_router", [App])) - end - end, + Router = router_module(App), Env = nova:get_environment(), Routes = get_routes(Router, Env), @@ -277,60 +290,56 @@ compile([App|Tl], Dispatch, Options) -> RouterFile = proplists:get_value(source, CompileParameters), Options1 = Options#{app => App, router_file => RouterFile}, - {ok, Dispatch1, Options2} = compile_paths(Routes, Dispatch, Options1), + {ok, Dispatch1, _Options2} = compile_paths(Routes, Dispatch, Options1), %% Take out the prefix for the app and store it in the persistent store StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), CompiledApps = StorageBackend:get(?NOVA_APPS, []), - CompiledApps0 = [{App, maps:get(prefix, Options, "/")}|CompiledApps], + CompiledApps0 = lists:keystore(App, 1, CompiledApps, {App, maps:get(prefix, Options, "/")}), StorageBackend:put(?NOVA_APPS, CompiledApps0), - compile(Tl, Dispatch1, Options2). + compile(Tl, Dispatch1, Options). + +%%-------------------------------------------------------------------- +%% @doc +%% The router module for an application. Either configured explicitly with +%% the `router_module' application environment key, or derived from the +%% application name using the convention for the language in use. +%% @end +%%-------------------------------------------------------------------- +router_module(App) -> + case application:get_env(App, router_module) of + {ok, RouterModule} -> + RouterModule; + undefined -> + case nova:detect_language() of + elixir -> + %% We build the router as App.Router + erlang:list_to_atom(lists:flatten(io_lib:format("~s.Router", [App]))); + _ -> + %% All other languages are using the app_router convention + erlang:list_to_atom(lists:flatten(io_lib:format("~s_router", [App]))) + end + end. compile_paths([], Dispatch, Options) -> {ok, Dispatch, Options}; compile_paths([RouteInfo|Tl], Dispatch, Options) -> App = maps:get(app, Options), RouterFile = maps:get(router_file, Options), - %% Fetch the global plugins - we need to check Options first to see what plugin-strategy we should use for this route: - Plugins = - case maps:get(plugin_strategy, Options, local_first) of - local_first -> - LocalPlugins = maps:get(plugins, RouteInfo, []), - GlobalPlugins = application:get_env(nova, plugins, []), - %% We need to make sure that the plugins are in the right order, so we use ukeysort to remove duplicates and keep the order of the first occurrence - lists:ukeysort(1, LocalPlugins ++ GlobalPlugins); - global_first -> - LocalPlugins = maps:get(plugins, RouteInfo, []), - GlobalPlugins = application:get_env(nova, plugins, []), - %% We need to make sure that the plugins are in the right order, so we use ukeysort to remove duplicates and keep the order of the first occurrence - lists:ukeysort(1, GlobalPlugins ++ LocalPlugins); - local_only -> - maps:get(plugins, RouteInfo, []); - global_only -> - application:get_env(nova, plugins, []); - {override, PluginList} when is_list(PluginList) -> - PluginList - end, + Plugins = resolve_plugins(maps:get(plugin_strategy, Options, local_or_global), RouteInfo, RouterFile), Secure = case maps:get(override_secure, Options, false) of false -> - case maps:get(secure, Options, maps:get(security, RouteInfo, false)) of - false -> - false; - {SMod, SFun} -> - ?LOG_DEPRECATED(<<"v0.9.24">>, <<"The {Mod,Fun} format have been deprecated for the 'secure'-section of a route table. Use the new format for routes.">>, RouterFile), - fun SMod:SFun/1; - SCallback -> - SCallback - end; - %% We override the secure value for this route (app level) with the value provided in options - SCallback -> - SCallback + normalize_secure(maps:get(secure, Options, maps:get(security, RouteInfo, false)), RouterFile); + %% The including application overrides the security callback the + %% sub-application declared for itself. + Override -> + normalize_secure(Override, RouterFile) end, Value = #nova_handler_value{secure = Secure, app = App, plugins = normalize_plugins(Plugins), @@ -343,11 +352,13 @@ compile_paths([RouteInfo|Tl], Dispatch, Options) -> %% We need to add this app info to nova-env NovaEnv = nova:get_env(apps, []), - NovaEnv0 = [{App, #{prefix => Prefix}} | NovaEnv], + NovaEnv0 = lists:keystore(App, 1, NovaEnv, {App, #{prefix => Prefix}}), nova:set_env(apps, NovaEnv0), - {ok, Dispatch1} = parse_url(Host, maps:get(routes, RouteInfo, []), #{prefix => Prefix, - router_file => maps:get(router_file, Options)}, + {ok, Dispatch1} = parse_url(Host, maps:get(routes, RouteInfo, []), + #{prefix => Prefix, + router_file => maps:get(router_file, Options), + insert_opts => maps:get(insert_opts, Options, #{})}, Value, Dispatch), Dispatch2 = compile(SubApps, Dispatch1, Options#{value => Value, prefix => Prefix}), @@ -358,9 +369,8 @@ parse_url(_Host, [], _Prefix, _Value, Tree) -> {ok, Tree}; parse_url(Host, [{StatusCode, Callback, Options}|Tl], T, Value, Tree) when is_integer(StatusCode) andalso is_function(Callback) -> Value0 = Value#nova_handler_value{callback = Callback}, - Res = lists:foldl(fun(Method, Tree0) -> - insert(Host, StatusCode, Method, Value0, Tree0) - end, Tree, maps:get(methods, Options, ['_'])), + Res = insert_methods(maps:get(methods, Options, ['_']), Host, StatusCode, Value0, Tree, + insert_opts(T), fun(M) -> M end), parse_url(Host, Tl, T, Value, Res); parse_url(Host, [{RemotePath, LocalPath}|Tl], T, Value = #nova_handler_value{}, Tree) when is_list(RemotePath), is_list(LocalPath) -> @@ -407,7 +417,7 @@ parse_url(Host, [{RemotePath, LocalPath, Options}|Tl], T = #{prefix := Prefix}, plugins = Value#nova_handler_value.plugins, secure = Secure }, - Tree0 = insert(Host, string:concat(Prefix, RemotePath), '_', Value0, Tree), + Tree0 = insert(Host, string:concat(Prefix, RemotePath), '_', Value0, Tree, insert_opts(T)), parse_url(Host, Tl, T, Value, Tree0); parse_url(Host, [{Path, {Mod, Func}, Options}|Tl], T, Value = #nova_handler_value{app = _App, secure = _Secure}, Tree) -> RouterFile = maps:get(router_file, T, undefined), @@ -428,17 +438,11 @@ parse_url(Host, [{Path, Callback, Options}|Tl], T = #{prefix := Prefix}, Value = 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 route">>, route => RealPath, app => App, method => Method, - router_file => maps:get(router_file, Options, undefined)}), - insert(Host, RealPath, BinMethod, Value1, Tree0) - end, Tree, Methods), + Value1 = Value0#nova_handler_value{callback = Callback}, + ?LOG_DEBUG(#{action => <<"Adding route">>, route => RealPath, app => App, methods => Methods, + router_file => maps:get(router_file, Options, undefined)}), + CompiledPaths = insert_methods(Methods, Host, RealPath, Value1, Tree, insert_opts(T), + fun method_to_binary/1), parse_url(Host, Tl, T, Value, CompiledPaths); OtherProtocol -> ?LOG_ERROR(#{reason => <<"Unknown protocol">>, protocol => OtherProtocol, @@ -459,7 +463,7 @@ parse_url(Host, ?LOG_DEBUG(#{action => <<"Adding route">>, protocol => <<"ws">>, route => Path, app => App, router_file => maps:get(router_file, T, undefined)}), RealPath = concat_strings(Prefix, Path), - CompiledPaths = insert(Host, RealPath, '_', Value0, Tree), + CompiledPaths = insert(Host, RealPath, '_', Value0, Tree, insert_opts(T)), parse_url(Host, Tl, T, Value, CompiledPaths). @@ -507,9 +511,23 @@ render_status_page(Host, StatusCode, Data, Req, Env) -> {ok, Req0#{resp_status_code => StatusCode}, Env0}. -insert(Host, Path, Combinator, Value, Tree) -> - try nova_routing_trie:insert(Host, Path, Combinator, Value, Tree) of - Tree0 -> Tree0 +insert_opts(T) -> + maps:get(insert_opts, T, #{}). + +insert_methods([], _Host, _Path, _Value, Tree, _Options, _ToComparator) -> + Tree; +insert_methods([Method|Tl], Host, Path, Value, Tree, Options, ToComparator) -> + Tree0 = insert(Host, Path, ToComparator(Method), Value, Tree, Options), + insert_methods(Tl, Host, Path, Value, Tree0, Options, ToComparator). + +insert(Host, Path, Combinator, Value, Tree, Options) -> + try nova_routing_trie:insert(Host, Path, Combinator, Value, Tree, Options) of + {ok, Tree0} -> + Tree0; + {error, conflict, Conflict} -> + ?LOG_ERROR(#{reason => <<"Conflicting route">>, route => Path, combinator => Combinator, + conflict => Conflict}), + throw({error, {route_conflict, Conflict}}) catch throw:Exception -> ?LOG_ERROR(#{reason => <<"Error when inserting route">>, route => Path, combinator => Combinator}), @@ -531,6 +549,67 @@ add_plugin(Plugin) -> StorageBackend:put(?NOVA_PLUGINS, Plugins1) end. +%%-------------------------------------------------------------------- +%% @doc +%% Work out which plugins apply to a route entry. +%% +%% `local_or_global' is the default and is how Nova has always behaved: a +%% route entry that declares `plugins' uses exactly those, otherwise it uses +%% the globally configured ones. The merging strategies exist for the cases +%% where you want both, and dedupe on `{Type, Module}' keeping the first +%% occurrence, so ordering within a phase is preserved. +%% @end +%%-------------------------------------------------------------------- +normalize_secure(false, _RouterFile) -> + false; +normalize_secure(true, RouterFile) -> + ?LOG_ERROR(#{reason => <<"'secure' must be false, a fun/1 or {Mod, Fun}. Ignoring 'true'.">>, + router_file => RouterFile}), + false; +normalize_secure({SMod, SFun}, RouterFile) when is_atom(SMod), is_atom(SFun) -> + ?LOG_DEPRECATED(<<"v0.9.24">>, <<"The {Mod,Fun} format have been deprecated for the 'secure'-section of a route table. Use the new format for routes.">>, RouterFile), + fun SMod:SFun/1; +normalize_secure(SCallback, _RouterFile) -> + SCallback. + +resolve_plugins(local_or_global, RouteInfo, _RouterFile) -> + maps:get(plugins, RouteInfo, global_plugins()); +resolve_plugins(local_first, RouteInfo, _RouterFile) -> + dedupe_plugins(local_plugins(RouteInfo) ++ global_plugins()); +resolve_plugins(global_first, RouteInfo, _RouterFile) -> + dedupe_plugins(global_plugins() ++ local_plugins(RouteInfo)); +resolve_plugins(local_only, RouteInfo, _RouterFile) -> + local_plugins(RouteInfo); +resolve_plugins(global_only, _RouteInfo, _RouterFile) -> + global_plugins(); +resolve_plugins({override, PluginList}, _RouteInfo, _RouterFile) when is_list(PluginList) -> + PluginList; +resolve_plugins(Strategy, RouteInfo, RouterFile) -> + ?LOG_ERROR(#{reason => <<"Unknown plugin_strategy, falling back to local_or_global">>, + plugin_strategy => Strategy, router_file => RouterFile}), + resolve_plugins(local_or_global, RouteInfo, RouterFile). + +local_plugins(RouteInfo) -> + maps:get(plugins, RouteInfo, []). + +global_plugins() -> + application:get_env(nova, plugins, []). + +dedupe_plugins(Plugins) -> + dedupe_plugins(Plugins, [], []). + +dedupe_plugins([], _Seen, Acc) -> + lists:reverse(Acc); +dedupe_plugins([Plugin|Tl], Seen, Acc) -> + Key = plugin_key(Plugin), + case lists:member(Key, Seen) of + true -> dedupe_plugins(Tl, Seen, Acc); + false -> dedupe_plugins(Tl, [Key|Seen], [Plugin|Acc]) + end. + +plugin_key({Type, PluginName, _Options}) -> {Type, PluginName}; +plugin_key(Plugin) -> Plugin. + normalize_plugins(Plugins) -> NormalizedPlugins = normalize_plugins(Plugins, []), [{Type, lists:reverse(TypePlugins)} || {Type, TypePlugins} <- NormalizedPlugins]. @@ -561,13 +640,6 @@ concat_strings(_Path1, Path2) when is_integer(Path2) -> concat_strings(Path1, Path2) when is_list(Path1), is_list(Path2) -> string:concat(Path1, Path2). -canonicalise([], Acc) -> - lists:reverse(Acc); -canonicalise([".." | _], []) -> - unsafe; -canonicalise([Seg | Rest], Acc) -> - canonicalise(Rest, [Seg | Acc]). - %% ============================ %% Callbacks for nova_router %% =========================== diff --git a/src/nova_routing_trie.erl b/src/nova_routing_trie.erl index 8cebba7..2286a0f 100644 --- a/src/nova_routing_trie.erl +++ b/src/nova_routing_trie.erl @@ -101,7 +101,7 @@ existing_methods := [comparator()] }. --export_type([trie/0, trie_node/0, route/0, bindings/0, comparator/0, conflict/0]). +-export_type([trie/0, trie_node/0, route/0, path/0, bindings/0, comparator/0, conflict/0]). %%==================================================================== %% API From 170385fa543baf9a84337678ea1a9895eb0a3acb Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 10 Aug 2026 11:45:40 +0200 Subject: [PATCH 17/21] test: port the suite off routing_tree Six modules only built an empty tree, so they are a module rename. nova_router_tests also had to take the {ok, Trie} insert return and the renamed lookup. 412 tests, 0 failures. --- test/nova_basic_handler_tests.erl | 4 ++-- test/nova_handler_tests.erl | 2 +- test/nova_plugin_handler_tests.erl | 2 +- test/nova_router_add_routes_tests.erl | 2 +- test/nova_router_tests.erl | 20 ++++++++++---------- test/nova_security_handler_tests.erl | 2 +- test/nova_ws_handler_tests.erl | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/test/nova_basic_handler_tests.erl b/test/nova_basic_handler_tests.erl index bfc9025..77b48c4 100644 --- a/test/nova_basic_handler_tests.erl +++ b/test/nova_basic_handler_tests.erl @@ -295,7 +295,7 @@ handle_status_code_only_test_() -> {setup, fun() -> Prev = nova_test_helper:setup_nova_env(), application:set_env(nova, dispatch_backend, persistent_term), - Tree = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}), persistent_term:put(nova_dispatch, Tree), Prev end, @@ -313,7 +313,7 @@ handle_status_with_headers_test_() -> {setup, fun() -> Prev = nova_test_helper:setup_nova_env(), application:set_env(nova, dispatch_backend, persistent_term), - Tree = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}), persistent_term:put(nova_dispatch, Tree), Prev end, diff --git a/test/nova_handler_tests.erl b/test/nova_handler_tests.erl index 5c547a5..655a84e 100644 --- a/test/nova_handler_tests.erl +++ b/test/nova_handler_tests.erl @@ -10,7 +10,7 @@ setup() -> application:set_env(nova, dispatch_backend, persistent_term), application:set_env(nova, render_error_pages, false), %% Set up dispatch table for render_response/3 - Tree = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}), persistent_term:put(nova_dispatch, Tree), persistent_term:put(nova_use_stacktrace, false), %% Start nova_handlers gen_server (registers ETS table + default handlers) diff --git a/test/nova_plugin_handler_tests.erl b/test/nova_plugin_handler_tests.erl index 5d94978..a50f8da 100644 --- a/test/nova_plugin_handler_tests.erl +++ b/test/nova_plugin_handler_tests.erl @@ -7,7 +7,7 @@ setup() -> Prev = nova_test_helper:setup_nova_env(), application:set_env(nova, dispatch_backend, persistent_term), - Tree = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}), persistent_term:put(nova_dispatch, Tree), persistent_term:put(nova_plugins, []), persistent_term:put(nova_use_stacktrace, false), diff --git a/test/nova_router_add_routes_tests.erl b/test/nova_router_add_routes_tests.erl index e858e14..8a69578 100644 --- a/test/nova_router_add_routes_tests.erl +++ b/test/nova_router_add_routes_tests.erl @@ -5,7 +5,7 @@ setup() -> Prev = nova_test_helper:setup_nova_env(), application:set_env(nova, dispatch_backend, persistent_term), - Tree = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}), persistent_term:put(nova_dispatch, Tree), persistent_term:put(nova_apps, []), persistent_term:put(nova_plugins, []), diff --git a/test/nova_router_tests.erl b/test/nova_router_tests.erl index 8dd45e5..23c2cb1 100644 --- a/test/nova_router_tests.erl +++ b/test/nova_router_tests.erl @@ -9,7 +9,7 @@ setup() -> Prev = nova_test_helper:setup_nova_env(), application:set_env(nova, dispatch_backend, persistent_term), %% Build a dispatch table with test routes - Tree0 = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree0 = nova_routing_trie:new(#{strict => false}), Callback = fun(_Req) -> {json, #{ok => true}} end, Value = #nova_handler_value{ app = test_app, @@ -18,14 +18,14 @@ setup() -> plugins = [{pre_request, [{fun nova_request_plugin:pre_request/4, #{}}]}], extra_state = #{test => true} }, - Tree1 = routing_tree:insert('_', "/users", <<"GET">>, Value, Tree0), - Tree2 = routing_tree:insert('_', "/users", <<"POST">>, Value#nova_handler_value{ + {ok, Tree1} = nova_routing_trie:insert('_', "/users", <<"GET">>, Value, Tree0), + {ok, Tree2} = nova_routing_trie:insert('_', "/users", <<"POST">>, Value#nova_handler_value{ callback = fun(_Req) -> {json, 201, #{}, #{created => true}} end }, Tree1), %% Route with path params - Tree3 = routing_tree:insert('_', "/users/:id", <<"GET">>, Value, Tree2), + {ok, Tree3} = nova_routing_trie:insert('_', "/users/:id", <<"GET">>, Value, Tree2), %% Pathinfo/wildcard route - Tree4 = routing_tree:insert('_', "/static/[...]", '_', + {ok, Tree4} = nova_routing_trie:insert('_', "/static/[...]", '_', Value#nova_handler_value{ callback = fun nova_file_controller:get_dir/1, extra_state = #{static => {priv_dir, test_app, "static"}} @@ -38,7 +38,7 @@ setup() -> plugins = [{pre_request, []}], secure = false }, - Tree5 = routing_tree:insert('_', "/ws", '_', CowboyValue, Tree4), + {ok, Tree5} = nova_routing_trie:insert('_', "/ws", '_', CowboyValue, Tree4), %% Status code route (custom 404) ErrorCallback = fun(_Req) -> {status, 404, #{}, <<"Custom not found">>} end, ErrorValue = #nova_handler_value{ @@ -48,7 +48,7 @@ setup() -> plugins = [], extra_state = #{} }, - Tree6 = routing_tree:insert('_', 404, '_', ErrorValue, Tree5), + {ok, Tree6} = nova_routing_trie:insert('_', 404, '_', ErrorValue, Tree5), persistent_term:put(nova_dispatch, Tree6), persistent_term:put(nova_apps, [{test_app, "/"}]), persistent_term:put(nova_plugins, []), @@ -397,10 +397,10 @@ add_plugin_duplicate_test_() -> insert_valid_test_() -> {setup, ?SETUP, ?CLEANUP, fun() -> - Tree = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree = nova_routing_trie:new(#{strict => false}), Value = #nova_handler_value{app = test_app, callback = fun(_) -> ok end}, - Tree1 = nova_router:insert('_', "/test", <<"GET">>, Value, Tree), - {ok, _, V} = routing_tree:lookup('_', <<"/test">>, <<"GET">>, Tree1), + Tree1 = nova_router:insert('_', "/test", <<"GET">>, Value, Tree, #{}), + {ok, _, V} = nova_routing_trie:find('_', <<"/test">>, <<"GET">>, Tree1), ?assertMatch(#nova_handler_value{app = test_app}, V) end}. diff --git a/test/nova_security_handler_tests.erl b/test/nova_security_handler_tests.erl index 814e20a..a3d456b 100644 --- a/test/nova_security_handler_tests.erl +++ b/test/nova_security_handler_tests.erl @@ -8,7 +8,7 @@ setup() -> Prev = nova_test_helper:setup_nova_env(), application:set_env(nova, dispatch_backend, persistent_term), - Tree = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}), persistent_term:put(nova_dispatch, Tree), persistent_term:put(nova_use_stacktrace, false), meck:new(cowboy_req, [passthrough, no_link]), diff --git a/test/nova_ws_handler_tests.erl b/test/nova_ws_handler_tests.erl index 43fa255..688230f 100644 --- a/test/nova_ws_handler_tests.erl +++ b/test/nova_ws_handler_tests.erl @@ -8,7 +8,7 @@ setup() -> Prev = nova_test_helper:setup_nova_env(), application:set_env(nova, dispatch_backend, persistent_term), application:set_env(nova, render_error_pages, false), - Tree = routing_tree:new(#{use_strict => false, convert_to_binary => true}), + Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}), persistent_term:put(nova_dispatch, Tree), persistent_term:put(nova_use_stacktrace, false), {ok, Pid} = nova_handlers:start_link(), From 6e49a690fc30fa60eaa9ef122b8715493f93a488 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 10 Aug 2026 11:55:46 +0200 Subject: [PATCH 18/21] test: boot a full-feature Nova application in-repo Nothing in this repo started Nova end to end, which is why a branch where the framework cannot boot at all survived review. The only integration gate was nova_request_app, a separate repository that has to be updated by hand whenever Nova changes. test/nova_test_app is a real Nova application - router, controllers, websocket controller, plugin, security callback and priv assets - with test/nova_test_sub_app mounted under a prefix through nova_apps. Between them they exercise plain routes, single and multiple bindings, literal-beats- binding precedence, every HTTP method, any-method routes, 405, redirects, custom status codes, extra_state, host-scoped routes, [...] static directories out of priv, security callbacks and auth_data, both plugin phases, custom error pages, controller crashes, websockets, prefixed sub-applications, and adding and removing an application at runtime. nova_full_app_SUITE drives it over a real Cowboy listener on a free port with httpc, plus a small hand-rolled websocket client so no client dependency is needed. The test profile picks the applications up through project_app_dirs, so none of it ships in the hex package. Two defects it caught immediately: - An application's own status-code route was unreachable. nova compiles first and routes are first-wins, so nova's default 404 and 500 always won and a {404, fun my_controller:not_found/1, #{}} entry in a router was silently ignored. nova is now compiled last, making its own error pages the defaults they were meant to be. - resolve_nova_apps/2 (#380) called lists:reverse/1 on an accumulator that was still being accumulated, so as soon as any nova_app declared nova_apps of its own the resolution order came out scrambled - depth-first ordering the function documents was not what it produced. It also looked up nested apps with the whole {App, Options} tuple as the application name, so a sub-application declared in tuple form never had its own nova_apps resolved at all. Rewritten with the seen-set kept separate from the output. --- rebar.config | 6 +- src/nova_sup.erl | 40 +- test/nova_full_app_SUITE.erl | 371 ++++++++++++++++++ test/nova_test_app/priv/assets/hello.txt | 1 + .../nova_test_app/priv/assets/nested/deep.txt | 1 + .../priv/assets/nested/index.html | 1 + .../controllers/nova_test_app_controller.erl | 56 +++ .../nova_test_app_ws_controller.erl | 31 ++ test/nova_test_app/src/nova_test_app.app.src | 9 + .../src/nova_test_app_plugin.erl | 42 ++ .../src/nova_test_app_router.erl | 57 +++ .../src/nova_test_app_security.erl | 13 + .../src/nova_test_sub_app.app.src | 9 + .../src/nova_test_sub_app_controller.erl | 6 + .../src/nova_test_sub_app_router.erl | 10 + 15 files changed, 640 insertions(+), 13 deletions(-) create mode 100644 test/nova_full_app_SUITE.erl create mode 100644 test/nova_test_app/priv/assets/hello.txt create mode 100644 test/nova_test_app/priv/assets/nested/deep.txt create mode 100644 test/nova_test_app/priv/assets/nested/index.html create mode 100644 test/nova_test_app/src/controllers/nova_test_app_controller.erl create mode 100644 test/nova_test_app/src/controllers/nova_test_app_ws_controller.erl create mode 100644 test/nova_test_app/src/nova_test_app.app.src create mode 100644 test/nova_test_app/src/nova_test_app_plugin.erl create mode 100644 test/nova_test_app/src/nova_test_app_router.erl create mode 100644 test/nova_test_app/src/nova_test_app_security.erl create mode 100644 test/nova_test_sub_app/src/nova_test_sub_app.app.src create mode 100644 test/nova_test_sub_app/src/nova_test_sub_app_controller.erl create mode 100644 test/nova_test_sub_app/src/nova_test_sub_app_router.erl diff --git a/rebar.config b/rebar.config index e268e6d..af2bd5e 100644 --- a/rebar.config +++ b/rebar.config @@ -21,7 +21,11 @@ {prod, [{relx, [{dev_mode, false}, {include_erts, true}]}]}, {test, [ {erl_opts, [debug_info, nowarn_export_all]}, - {deps, [{proper, "1.4.0"}, {meck, "1.1.1"}]} + {deps, [{proper, "1.4.0"}, {meck, "1.1.1"}]}, + %% nova_test_app is a real Nova application that nova_full_app_SUITE + %% boots on a Cowboy listener, so framework-level regressions are + %% caught here rather than downstream. + {project_app_dirs, [".", "test/nova_test_app", "test/nova_test_sub_app"]} ]} ]}. diff --git a/src/nova_sup.erl b/src/nova_sup.erl index 09731ef..8635bdb 100644 --- a/src/nova_sup.erl +++ b/src/nova_sup.erl @@ -21,6 +21,8 @@ -define(NOVA_STD_PORT, 8080). -define(NOVA_STD_SSL_PORT, 8443). +-type nova_app() :: atom() | {atom(), map()}. + %%%=================================================================== %%% API functions @@ -150,7 +152,10 @@ start_cowboy(Configuration) -> throw({error, no_nova_app_defined}); App -> ExtraApps = application:get_env(App, nova_apps, []), - nova_router:compile(resolve_nova_apps([nova, App | ExtraApps], [])) + %% nova is compiled last so that its own 404/500 routes act as + %% defaults. Routes are first-wins, so compiling nova first + %% made an application's own status-code routes unreachable. + nova_router:compile(resolve_nova_apps([App | ExtraApps] ++ [nova])) end, CowboyOptions2 = @@ -227,17 +232,28 @@ get_version(Application) -> %% @doc Recursively resolve nested nova_apps. %% Each nova_app can declare its own nova_apps dependencies. %% Dependencies are resolved depth-first so child app routes -%% are registered before the parent. --spec resolve_nova_apps([atom()], [atom()]) -> [atom()]. -resolve_nova_apps([], Acc) -> - lists:reverse(Acc); -resolve_nova_apps([App | Rest], Acc) -> - case lists:member(App, Acc) of +%% are registered before the parent. An application already resolved is +%% skipped, so a cycle terminates. +-spec resolve_nova_apps([nova_app()]) -> [nova_app()]. +resolve_nova_apps(Apps) -> + {Resolved, _Seen} = resolve_nova_apps(Apps, [], []), + Resolved. + +-spec resolve_nova_apps([nova_app()], [nova_app()], [atom()]) -> {[nova_app()], [atom()]}. +resolve_nova_apps([], Acc, Seen) -> + {lists:reverse(Acc), Seen}; +resolve_nova_apps([App | Rest], Acc, Seen) -> + Name = nova_app_name(App), + case lists:member(Name, Seen) of true -> - %% Already resolved — skip to prevent cycles - resolve_nova_apps(Rest, Acc); + resolve_nova_apps(Rest, Acc, Seen); false -> - Nested = application:get_env(App, nova_apps, []), - Acc1 = resolve_nova_apps(Nested, [App | Acc]), - resolve_nova_apps(Rest, Acc1) + Nested = application:get_env(Name, nova_apps, []), + {NestedApps, Seen0} = resolve_nova_apps(Nested, [], [Name | Seen]), + resolve_nova_apps(Rest, [App | lists:reverse(NestedApps)] ++ Acc, Seen0) end. + +%% A nova_app is either the application name or {Name, Options}. +-spec nova_app_name(nova_app()) -> atom(). +nova_app_name({App, _Options}) -> App; +nova_app_name(App) -> App. diff --git a/test/nova_full_app_SUITE.erl b/test/nova_full_app_SUITE.erl new file mode 100644 index 0000000..fddde4f --- /dev/null +++ b/test/nova_full_app_SUITE.erl @@ -0,0 +1,371 @@ +%%% End-to-end suite for Nova. +%%% +%%% This boots a real Nova application (nova_test_app, plus nova_test_sub_app +%%% mounted under a prefix) on a real Cowboy listener and drives it over HTTP +%%% and WebSocket. It exists so that framework-level regressions - a router +%%% that will not compile, a dispatch table that cannot be looked up, a +%%% listener that never binds - fail here rather than in a downstream +%%% application. +%%% +%%% If you add a feature to Nova, add a route for it to nova_test_app_router +%%% and a case here. +-module(nova_full_app_SUITE). + +-compile([export_all, nowarn_export_all]). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +all() -> + [ + {group, routing}, + {group, static_files}, + {group, security}, + {group, plugins}, + {group, errors}, + {group, websocket}, + {group, sub_apps}, + {group, runtime_routes} + ]. + +groups() -> + [ + {routing, [parallel], [ + root_route, + json_route, + single_binding, + multiple_bindings, + literal_beats_binding, + all_declared_methods, + any_method_route, + method_not_allowed, + redirect, + custom_status_code, + extra_state_reaches_controller, + host_scoped_route + ]}, + {static_files, [parallel], [ + static_file_from_priv, + static_file_nested, + static_directory_index, + static_file_missing + ]}, + {security, [parallel], [ + secure_route_rejects_anonymous, + secure_route_accepts_token, + auth_data_reaches_controller + ]}, + {plugins, [], [ + pre_and_post_request_plugins_run + ]}, + {errors, [parallel], [ + custom_not_found, + controller_crash_is_a_500 + ]}, + {websocket, [], [ + websocket_echo + ]}, + {sub_apps, [parallel], [ + sub_app_mounted_under_prefix + ]}, + {runtime_routes, [], [ + add_and_remove_application + ]} + ]. + +%%==================================================================== +%% Setup +%%==================================================================== + +init_per_suite(Config) -> + Port = free_port(), + application:load(nova), + application:set_env(nova, bootstrap_application, nova_test_app), + application:set_env(nova, cowboy_configuration, #{port => Port}), + application:set_env(nova, environment, test), + application:set_env(nova, use_stacktrace, true), + application:set_env(nova, plugins, []), + %% nova_test_app_plugin counts the phases it runs in through this. + persistent_term:put(nova_test_app_plugin, counters:new(2, [write_concurrency])), + {ok, _Started} = application:ensure_all_started(nova_test_app), + {ok, _} = application:ensure_all_started(inets), + [{port, Port}, {base, "http://localhost:" ++ integer_to_list(Port)} | Config]. + +end_per_suite(_Config) -> + persistent_term:erase(nova_test_app_plugin), + application:stop(nova_test_app), + application:stop(nova), + ok. + +%%==================================================================== +%% Routing +%%==================================================================== + +root_route(Config) -> + {200, _Headers, Body} = get(Config, "/"), + ?assertEqual(<<"index">>, Body). + +json_route(Config) -> + {200, Headers, Body} = get(Config, "/json"), + ?assertMatch(<<"application/json", _/binary>>, header(<<"content-type">>, Headers)), + ?assertEqual(#{<<"ok">> => true, <<"from">> => <<"nova_test_app">>}, json(Body)). + +single_binding(Config) -> + {200, _Headers, Body} = get(Config, "/echo/42"), + ?assertEqual(#{<<"id">> => <<"42">>}, json(Body)). + +multiple_bindings(Config) -> + {200, _Headers, Body} = get(Config, "/echo/42/comments/7"), + ?assertEqual(#{<<"id">> => <<"42">>, <<"comment_id">> => <<"7">>}, json(Body)). + +%% A literal segment must win over a binding at the same depth, and the +%% binding must still match everything else. +literal_beats_binding(Config) -> + {200, _H1, Literal} = get(Config, "/users/new"), + ?assertEqual(#{<<"matched">> => <<"literal">>}, json(Literal)), + {200, _H2, Binding} = get(Config, "/users/123"), + ?assertEqual(#{<<"id">> => <<"123">>}, json(Binding)). + +%% Nova answers a bare {json, _} with 201 on POST and 200 otherwise, so the +%% expected status is method-dependent. +all_declared_methods(Config) -> + [begin + {Status, _Headers, Body} = request(Config, Method, "/methods"), + ?assertEqual(expected_status(Method), Status), + ?assertEqual(#{<<"method">> => list_to_binary(string:uppercase(atom_to_list(Method)))}, + json(Body)) + end || Method <- [get, post, put, delete, patch]]. + +any_method_route(Config) -> + [begin + {Status, _Headers, _Body} = request(Config, Method, "/any-method"), + ?assertEqual(expected_status(Method), Status) + end || Method <- [get, post, put, delete]]. + +expected_status(post) -> 201; +expected_status(_) -> 200. + +method_not_allowed(Config) -> + {405, Headers, _Body} = request(Config, post, "/get-only"), + ?assertEqual(<<"GET">>, header(<<"allow">>, Headers)). + +redirect(Config) -> + {302, Headers, _Body} = get_no_redirect(Config, "/redirect"), + ?assertEqual(<<"/json">>, header(<<"location">>, Headers)). + +custom_status_code(Config) -> + {418, _Headers, Body} = get(Config, "/teapot"), + ?assertEqual(<<"short and stout">>, Body). + +extra_state_reaches_controller(Config) -> + {200, _Headers, Body} = get(Config, "/extra"), + ?assertEqual(#{<<"answer">> => 42}, json(Body)). + +%% Host-scoped routes are only served for their host, and the catch-all host +%% tree is not consulted for them. +host_scoped_route(Config) -> + {200, _Headers, Body} = get(Config, "/host", [{"host", "api.localhost"}]), + ?assertEqual(#{<<"host_scoped">> => true}, json(Body)), + {404, _H, _B} = get(Config, "/host"). + +%%==================================================================== +%% Static files +%%==================================================================== + +static_file_from_priv(Config) -> + {200, _Headers, Body} = get(Config, "/assets/hello.txt"), + ?assertEqual(<<"hello from priv\n">>, Body). + +static_file_nested(Config) -> + {200, _Headers, Body} = get(Config, "/assets/nested/deep.txt"), + ?assertEqual(<<"nested file\n">>, Body). + +static_directory_index(Config) -> + {200, _Headers, Body} = get(Config, "/assets/nested/index.html"), + ?assertMatch(<<"", _/binary>>, Body). + +static_file_missing(Config) -> + {404, _Headers, _Body} = get(Config, "/assets/does-not-exist.txt"). + +%%==================================================================== +%% Security +%%==================================================================== + +secure_route_rejects_anonymous(Config) -> + {401, _Headers, _Body} = get(Config, "/secure/"). + +secure_route_accepts_token(Config) -> + {200, _Headers, Body} = get(Config, "/secure/", [{"authorization", "Bearer let-me-in"}]), + ?assertEqual(#{<<"secret">> => true}, json(Body)). + +auth_data_reaches_controller(Config) -> + {200, _Headers, Body} = get(Config, "/secure/data", [{"authorization", "Bearer let-me-in"}]), + ?assertEqual(#{<<"user">> => <<"tester">>, <<"role">> => <<"admin">>}, json(Body)). + +%%==================================================================== +%% Plugins +%%==================================================================== + +%% pre_request can still touch the response; post_request runs after +%% nova_handler has replied, so it is observed through the plugin's counter. +pre_and_post_request_plugins_run(Config) -> + Counters = persistent_term:get(nova_test_app_plugin), + PreBefore = counters:get(Counters, 1), + PostBefore = counters:get(Counters, 2), + + {200, Headers, _Body} = get(Config, "/json"), + ?assertEqual(<<"1">>, header(<<"x-nova-pre-request">>, Headers)), + + ?assert(counters:get(Counters, 1) > PreBefore), + ?assert(counters:get(Counters, 2) > PostBefore). + +%%==================================================================== +%% Errors +%%==================================================================== + +custom_not_found(Config) -> + {404, _Headers, Body} = get(Config, "/no-such-route"), + ?assertEqual(<<"custom not found">>, Body). + +controller_crash_is_a_500(Config) -> + {500, _Headers, _Body} = get(Config, "/crash"). + +%%==================================================================== +%% WebSocket +%%==================================================================== + +websocket_echo(Config) -> + {ok, Socket} = ws_connect(Config, "/ws"), + ?assertEqual(<<"connected">>, ws_recv(Socket)), + ok = ws_send(Socket, <<"ping">>), + ?assertEqual(<<"pong">>, ws_recv(Socket)), + ok = ws_send(Socket, <<"hello">>), + ?assertEqual(<<"echo:hello">>, ws_recv(Socket)), + gen_tcp:close(Socket). + +%%==================================================================== +%% Sub-applications +%%==================================================================== + +sub_app_mounted_under_prefix(Config) -> + {200, _Headers, Body} = get(Config, "/sub/hello"), + ?assertEqual(#{<<"app">> => <<"nova_test_sub_app">>}, json(Body)). + +%%==================================================================== +%% Runtime route changes +%%==================================================================== + +%% Adding routes at runtime must not disturb the routes already registered, +%% and removing an application must take only its own routes with it. +add_and_remove_application(Config) -> + ok = nova_router:add_routes(runtime_app, + [#{routes => [{"/runtime", + fun(_Req) -> {json, #{runtime => true}} end, + #{methods => [get]}}]}]), + {200, _H1, Body} = get(Config, "/runtime"), + ?assertEqual(#{<<"runtime">> => true}, json(Body)), + ?assertMatch({runtime_app, _}, lists:keyfind(runtime_app, 1, nova_router:compiled_apps())), + + ok = nova_router:remove_application(runtime_app), + {404, _H2, _B2} = get(Config, "/runtime"), + ?assertEqual(false, lists:keyfind(runtime_app, 1, nova_router:compiled_apps())), + + %% Everything the other applications registered is still there. + {200, _H3, _B3} = get(Config, "/json"), + {200, _H4, _B4} = get(Config, "/sub/hello"), + {200, _H5, _B5} = get(Config, "/assets/hello.txt"). + +%%==================================================================== +%% HTTP helpers +%%==================================================================== + +get(Config, Path) -> + get(Config, Path, []). + +get(Config, Path, Headers) -> + do_request(get, url(Config, Path), Headers, [{autoredirect, false}]). + +get_no_redirect(Config, Path) -> + do_request(get, url(Config, Path), [], [{autoredirect, false}]). + +request(Config, Method, Path) -> + do_request(Method, url(Config, Path), [], [{autoredirect, false}]). + +do_request(Method, Url, Headers, Options) -> + Request = + case Method of + get -> {Url, Headers}; + _ -> {Url, Headers, "application/json", <<"{}">>} + end, + {ok, {{_Version, Status, _Reason}, RespHeaders, Body}} = + httpc:request(Method, Request, Options, [{body_format, binary}]), + {Status, RespHeaders, Body}. + +url(Config, Path) -> + ?config(base, Config) ++ Path. + +header(Name, Headers) -> + Lower = string:lowercase(binary_to_list(Name)), + case lists:keyfind(Lower, 1, [{string:lowercase(K), V} || {K, V} <- Headers]) of + {_Key, Value} -> list_to_binary(Value); + false -> undefined + end. + +json(Body) -> + {ok, Decoded} = thoas:decode(Body), + Decoded. + +free_port() -> + {ok, Socket} = gen_tcp:listen(0, [{reuseaddr, true}]), + {ok, Port} = inet:port(Socket), + ok = gen_tcp:close(Socket), + Port. + +%%==================================================================== +%% Minimal WebSocket client +%% +%% Only enough of RFC 6455 to open a connection and exchange short unmasked +%% text frames, so that the suite does not need a WebSocket client dependency. +%%==================================================================== + +ws_connect(Config, Path) -> + Port = ?config(port, Config), + {ok, Socket} = gen_tcp:connect("localhost", Port, [binary, {active, false}, {packet, raw}]), + Key = base64:encode(crypto:strong_rand_bytes(16)), + Handshake = [ + "GET ", Path, " HTTP/1.1\r\n", + "Host: localhost:", integer_to_list(Port), "\r\n", + "Upgrade: websocket\r\n", + "Connection: Upgrade\r\n", + "Sec-WebSocket-Key: ", Key, "\r\n", + "Sec-WebSocket-Version: 13\r\n\r\n" + ], + ok = gen_tcp:send(Socket, Handshake), + {ok, Response} = gen_tcp:recv(Socket, 0, 5000), + case binary:match(Response, <<"101">>) of + nomatch -> {error, {handshake_failed, Response}}; + _ -> {ok, Socket} + end. + +ws_send(Socket, Payload) -> + Mask = crypto:strong_rand_bytes(4), + Masked = mask(Payload, Mask, 0, <<>>), + Length = byte_size(Payload), + true = Length < 126, + gen_tcp:send(Socket, <<1:1, 0:3, 1:4, 1:1, Length:7, Mask/binary, Masked/binary>>). + +ws_recv(Socket) -> + {ok, <<_Fin:1, _Rsv:3, _Opcode:4, 0:1, Length:7>>} = gen_tcp:recv(Socket, 2, 5000), + true = Length < 126, + case Length of + 0 -> <<>>; + _ -> + {ok, Payload} = gen_tcp:recv(Socket, Length, 5000), + Payload + end. + +mask(<<>>, _Mask, _Index, Acc) -> + Acc; +mask(<>, Mask, Index, Acc) -> + MaskByte = binary:at(Mask, Index rem 4), + mask(Rest, Mask, Index + 1, <>). diff --git a/test/nova_test_app/priv/assets/hello.txt b/test/nova_test_app/priv/assets/hello.txt new file mode 100644 index 0000000..28a5c6a --- /dev/null +++ b/test/nova_test_app/priv/assets/hello.txt @@ -0,0 +1 @@ +hello from priv diff --git a/test/nova_test_app/priv/assets/nested/deep.txt b/test/nova_test_app/priv/assets/nested/deep.txt new file mode 100644 index 0000000..6bd82e7 --- /dev/null +++ b/test/nova_test_app/priv/assets/nested/deep.txt @@ -0,0 +1 @@ +nested file diff --git a/test/nova_test_app/priv/assets/nested/index.html b/test/nova_test_app/priv/assets/nested/index.html new file mode 100644 index 0000000..9e5791c --- /dev/null +++ b/test/nova_test_app/priv/assets/nested/index.html @@ -0,0 +1 @@ +index diff --git a/test/nova_test_app/src/controllers/nova_test_app_controller.erl b/test/nova_test_app/src/controllers/nova_test_app_controller.erl new file mode 100644 index 0000000..fe5d612 --- /dev/null +++ b/test/nova_test_app/src/controllers/nova_test_app_controller.erl @@ -0,0 +1,56 @@ +-module(nova_test_app_controller). + +-export([ + index/1, + json/1, + echo/1, + literal/1, + method/1, + redirect/1, + teapot/1, + crash/1, + extra/1, + secret/1, + auth_data/1, + host/1, + not_found/1 + ]). + +index(_Req) -> + {status, 200, #{<<"content-type">> => <<"text/plain">>}, <<"index">>}. + +json(_Req) -> + {json, #{ok => true, from => <<"nova_test_app">>}}. + +echo(#{bindings := Bindings}) -> + {json, Bindings}. + +literal(_Req) -> + {json, #{matched => <<"literal">>}}. + +method(#{method := Method}) -> + {json, #{method => Method}}. + +redirect(_Req) -> + {redirect, "/json"}. + +teapot(_Req) -> + {status, 418, #{}, <<"short and stout">>}. + +crash(_Req) -> + erlang:error(deliberate_crash). + +extra(#{extra_state := ExtraState}) -> + {json, #{answer => maps:get(answer, ExtraState, undefined)}}. + +secret(_Req) -> + {json, #{secret => true}}. + +auth_data(#{auth_data := AuthData}) -> + {json, AuthData}. + +host(_Req) -> + {json, #{host_scoped => true}}. + +not_found(_Req) -> + {status, 404, #{<<"content-type">> => <<"text/plain">>}, <<"custom not found">>}. diff --git a/test/nova_test_app/src/controllers/nova_test_app_ws_controller.erl b/test/nova_test_app/src/controllers/nova_test_app_ws_controller.erl new file mode 100644 index 0000000..337490f --- /dev/null +++ b/test/nova_test_app/src/controllers/nova_test_app_ws_controller.erl @@ -0,0 +1,31 @@ +-module(nova_test_app_ws_controller). +-behaviour(nova_websocket). + +-export([ + init/1, + websocket_init/1, + websocket_handle/2, + websocket_info/2, + terminate/3 + ]). + +init(Req) -> + {ok, #{req => Req, received => 0}}. + +websocket_init(State) -> + {reply, {text, <<"connected">>}, State}. + +websocket_handle({text, <<"ping">>}, State) -> + {reply, {text, <<"pong">>}, State}; +websocket_handle({text, <<"close">>}, State) -> + {stop, State}; +websocket_handle({text, Message}, State = #{received := Received}) -> + {reply, {text, <<"echo:", Message/binary>>}, State#{received => Received + 1}}; +websocket_handle(_Frame, State) -> + {ok, State}. + +websocket_info(_Info, State) -> + {ok, State}. + +terminate(_Reason, _Req, _State) -> + ok. diff --git a/test/nova_test_app/src/nova_test_app.app.src b/test/nova_test_app/src/nova_test_app.app.src new file mode 100644 index 0000000..853b8bf --- /dev/null +++ b/test/nova_test_app/src/nova_test_app.app.src @@ -0,0 +1,9 @@ +{application, nova_test_app, + [{description, "Full-feature Nova application used to test the framework end to end"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib, nova]}, + {env, [{nova_apps, [{nova_test_sub_app, #{prefix => "/sub"}}]}]}, + {modules, []}, + {licenses, ["Apache-2.0"]} + ]}. diff --git a/test/nova_test_app/src/nova_test_app_plugin.erl b/test/nova_test_app/src/nova_test_app_plugin.erl new file mode 100644 index 0000000..130fa4b --- /dev/null +++ b/test/nova_test_app/src/nova_test_app_plugin.erl @@ -0,0 +1,42 @@ +%%% Records that it ran, so the test suite can assert both plugin phases fire. +%%% +%%% pre_request can still set response headers, but post_request runs after +%%% nova_handler has already replied, so it counts through a shared counter +%%% instead. The suite installs the counter under nova_test_app_plugin in +%%% persistent_term; without it the plugin is a no-op. +-module(nova_test_app_plugin). +-behaviour(nova_plugin). + +-export([ + pre_request/4, + post_request/4, + plugin_info/0 + ]). + +-define(COUNTERS, nova_test_app_plugin). +-define(PRE_REQUEST, 1). +-define(POST_REQUEST, 2). + +pre_request(Req, _Env, _Options, State) -> + bump(?PRE_REQUEST), + {ok, cowboy_req:set_resp_header(<<"x-nova-pre-request">>, <<"1">>, Req), State}. + +post_request(Req, _Env, _Options, State) -> + bump(?POST_REQUEST), + {ok, Req, State}. + +bump(Index) -> + case persistent_term:get(?COUNTERS, undefined) of + undefined -> ok; + Ref -> counters:add(Ref, Index, 1) + end. + +plugin_info() -> + #{ + title => <<"nova_test_app_plugin">>, + version => <<"1.0.0">>, + url => <<"https://github.com/novaframework/nova">>, + authors => [<<"Nova team ">>], + description => <<"Test plugin that marks the request in both phases">>, + options => [] + }. diff --git a/test/nova_test_app/src/nova_test_app_router.erl b/test/nova_test_app/src/nova_test_app_router.erl new file mode 100644 index 0000000..223f1b5 --- /dev/null +++ b/test/nova_test_app/src/nova_test_app_router.erl @@ -0,0 +1,57 @@ +%%% Router for the end-to-end test application. +%%% +%%% Between this module and nova_test_sub_app_router, every routing feature +%%% Nova documents should appear at least once. If you add a feature to the +%%% router, add a route for it here and a case to nova_full_app_SUITE. +-module(nova_test_app_router). +-behaviour(nova_router). + +-export([routes/1]). + +routes(_Environment) -> + [ + %% Plain routes, bindings, methods, plugins and a catch-all static dir. + #{prefix => "", + security => false, + plugins => [{pre_request, nova_test_app_plugin, #{}}, + {post_request, nova_test_app_plugin, #{}}], + routes => [ + {"/", fun nova_test_app_controller:index/1, #{methods => [get]}}, + {"/json", fun nova_test_app_controller:json/1, #{methods => [get]}}, + {"/echo/:id", fun nova_test_app_controller:echo/1, #{methods => [get]}}, + {"/echo/:id/comments/:comment_id", + fun nova_test_app_controller:echo/1, #{methods => [get]}}, + {"/users/new", fun nova_test_app_controller:literal/1, #{methods => [get]}}, + {"/users/:id", fun nova_test_app_controller:echo/1, #{methods => [get]}}, + {"/methods", fun nova_test_app_controller:method/1, + #{methods => [get, post, put, delete, patch]}}, + {"/any-method", fun nova_test_app_controller:method/1, #{}}, + {"/get-only", fun nova_test_app_controller:method/1, #{methods => [get]}}, + {"/redirect", fun nova_test_app_controller:redirect/1, #{methods => [get]}}, + {"/teapot", fun nova_test_app_controller:teapot/1, #{methods => [get]}}, + {"/crash", fun nova_test_app_controller:crash/1, #{methods => [get]}}, + {"/extra", fun nova_test_app_controller:extra/1, + #{methods => [get], extra_state => #{answer => 42}}}, + {"/assets/[...]", "assets"}, + {"/ws", nova_test_app_ws_controller, #{protocol => ws}}, + + %% Status-code routes. Nova registers its own 404 and 500, + %% and an application may override them. + {404, fun nova_test_app_controller:not_found/1, #{}} + ]}, + + %% A prefixed group behind a security callback. + #{prefix => "/secure", + security => fun nova_test_app_security:check/1, + routes => [ + {"/", fun nova_test_app_controller:secret/1, #{methods => [get]}}, + {"/data", fun nova_test_app_controller:auth_data/1, #{methods => [get]}} + ]}, + + %% A host-scoped route. Only served when the Host header matches. + #{prefix => "", + host => <<"api.localhost">>, + routes => [ + {"/host", fun nova_test_app_controller:host/1, #{methods => [get]}} + ]} + ]. diff --git a/test/nova_test_app/src/nova_test_app_security.erl b/test/nova_test_app/src/nova_test_app_security.erl new file mode 100644 index 0000000..9238fd1 --- /dev/null +++ b/test/nova_test_app/src/nova_test_app_security.erl @@ -0,0 +1,13 @@ +%%% Security callback for the /secure prefix. Accepts a fixed bearer token and +%%% hands back auth data so the suite can check it reaches the controller. +-module(nova_test_app_security). + +-export([check/1]). + +check(Req) -> + case cowboy_req:header(<<"authorization">>, Req) of + <<"Bearer let-me-in">> -> + {true, #{user => <<"tester">>, role => <<"admin">>}}; + _ -> + false + end. diff --git a/test/nova_test_sub_app/src/nova_test_sub_app.app.src b/test/nova_test_sub_app/src/nova_test_sub_app.app.src new file mode 100644 index 0000000..9db1058 --- /dev/null +++ b/test/nova_test_sub_app/src/nova_test_sub_app.app.src @@ -0,0 +1,9 @@ +{application, nova_test_sub_app, + [{description, "Nova application mounted under a prefix by nova_test_app"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib, nova]}, + {env, []}, + {modules, []}, + {licenses, ["Apache-2.0"]} + ]}. diff --git a/test/nova_test_sub_app/src/nova_test_sub_app_controller.erl b/test/nova_test_sub_app/src/nova_test_sub_app_controller.erl new file mode 100644 index 0000000..2bc3064 --- /dev/null +++ b/test/nova_test_sub_app/src/nova_test_sub_app_controller.erl @@ -0,0 +1,6 @@ +-module(nova_test_sub_app_controller). + +-export([hello/1]). + +hello(_Req) -> + {json, #{app => <<"nova_test_sub_app">>}}. diff --git a/test/nova_test_sub_app/src/nova_test_sub_app_router.erl b/test/nova_test_sub_app/src/nova_test_sub_app_router.erl new file mode 100644 index 0000000..022d0f2 --- /dev/null +++ b/test/nova_test_sub_app/src/nova_test_sub_app_router.erl @@ -0,0 +1,10 @@ +-module(nova_test_sub_app_router). +-behaviour(nova_router). + +-export([routes/1]). + +routes(_Environment) -> + [#{prefix => "", + routes => [ + {"/hello", fun nova_test_sub_app_controller:hello/1, #{methods => [get]}} + ]}]. From 3d13c27bb1985d467bee7b9ed147a6cbac9249de Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 10 Aug 2026 11:56:18 +0200 Subject: [PATCH 19/21] ci: run common test on every OTP version The end-to-end suite is only a gate if CI runs it. Uploads the CT logs on failure so a red run can be diagnosed without reproducing locally. --- .github/workflows/erlang.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index 0747f2a..a7df54f 100644 --- a/.github/workflows/erlang.yml +++ b/.github/workflows/erlang.yml @@ -59,6 +59,39 @@ jobs: ${{runner.os}}-rebar3-${{matrix.otp}}-${{matrix.rebar3}}- - name: Run eunit run: rebar3 eunit + ct: + needs: [build] + runs-on: ubuntu-24.04 + name: CT Erlang/OTP ${{matrix.otp}} / rebar3 ${{matrix.rebar3}} + strategy: + fail-fast: false + matrix: + otp: ['27.3.4.11', '28.5', '29.0'] + rebar3: ['3.25.0'] + steps: + - uses: actions/checkout@v6 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + rebar3-version: ${{matrix.rebar3}} + version-type: strict + - name: Cache rebar3 deps and build + uses: actions/cache@v4 + with: + path: | + ~/.cache/rebar3 + _build + key: ${{runner.os}}-rebar3-${{matrix.otp}}-${{matrix.rebar3}}-${{hashFiles('rebar.lock')}} + restore-keys: | + ${{runner.os}}-rebar3-${{matrix.otp}}-${{matrix.rebar3}}- + - name: Run common test + run: rebar3 ct + - name: Upload CT logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ct-logs-${{matrix.otp}} + path: _build/test/logs dialyzer: runs-on: ubuntu-24.04 name: Dialyzer Erlang/OTP ${{matrix.otp}} / rebar3 ${{matrix.rebar3}} From 4739882d4544b98a6e685b11b0d5ed33d9709092 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 10 Aug 2026 12:02:21 +0200 Subject: [PATCH 20/21] feat(sup): multiple Cowboy listeners with per-listener routing tables Nova bound exactly one listener. This finishes the runtime application lifecycle the branch started, and makes multiple ports mean something. Per-listener routing tables. Every listener carries the key of the routing table it serves in its Cowboy env, and nova_router reads it from there. Without this all listeners shared one global nova_dispatch keyed only on host, path and method, so starting a second application on a second port made each listener answer for the other's routes - a second socket and nothing else. nova_router gains compile/2, add_routes/3, remove_application/2, compiled_apps/1 and delete_dispatch/1; every existing arity keeps addressing the default table, so nothing outside changes. The listener registry. #nova_listener{} is keyed on the ranch ref with {keypos, ...}, so the table holds one row per listener. It was created without a keypos, which made every row key on the record tag: the registry could only ever hold one entry, remove_application/1 answered {error, not_found} for every real application, and get_started_applications/0 could never list more than one. The table is public because the API is called by whoever wants an application started, not by the supervisor process, and creating it is idempotent so a nova_sup restart does not crash on the existing name. add_application/2 on an already-bound host and port now attaches the application to that listener instead of returning a bare ok into a case with no matching clause. That is the scenario guides/multi-app.md advertises, and it crashed with {case_clause, ok}. Starting an application that is already running is now {error, {already_started, App}} rather than a second bind attempt. remove_application/1 removes the application's routes, and stops the listener and forgets its routing table only once no applications are left on it. It previously stopped the listener and left the routes behind with a TODO. The already-bound check compares the port a configuration actually binds. It only ever read the port key, so a TLS listener on ssl_port was invisible to it: a TLS application double-bound, and a plaintext application on that port matched the TLS entry. The clear and TLS paths now share one bind and one registration helper rather than registering in different orders. Ranch refs are {nova_listener, App, Port}. Concatenating the application name and port into an atom collided - foo1 on 8080 and foo on 18080 both produced foo18080, so stopping one stopped the other - and grew the atom table on every add and remove cycle. The bootstrap listener keeps the nova_listener ref it has always had. Graceful shutdown drains every listener. nova_app:prep_stop/1 suspended, drained and stopped the single nova_listener atom, so any additional listener kept accepting connections through the drain and was killed outright at exit. Suspending and inspecting are now tolerant of a listener that has already gone, so a runtime removal cannot fail the shutdown. nova_multi_listener_SUITE covers all of it: two applications on two ports serving only their own routes, attaching to a bound port, double-start, teardown releasing the port, and removal leaving the other listener intact. --- src/nova_app.erl | 41 +++- src/nova_router.erl | 145 +++++++++--- src/nova_sup.erl | 346 ++++++++++++++++++++++------- test/nova_multi_listener_SUITE.erl | 149 +++++++++++++ 4 files changed, 558 insertions(+), 123 deletions(-) create mode 100644 test/nova_multi_listener_SUITE.erl diff --git a/src/nova_app.erl b/src/nova_app.erl index f73943d..6579614 100644 --- a/src/nova_app.erl +++ b/src/nova_app.erl @@ -40,24 +40,41 @@ graceful_shutdown() -> false -> ok end, - ?LOG_NOTICE(#{msg => <<"Suspending listener">>}), - ranch:suspend_listener(nova_listener), + Listeners = nova_sup:listeners(), + ?LOG_NOTICE(#{msg => <<"Suspending listeners">>, listeners => Listeners}), + [suspend(Listener) || Listener <- Listeners], DrainTimeout = application:get_env(nova, shutdown_drain_timeout, 15000), ?LOG_NOTICE(#{msg => <<"Draining connections">>, timeout_ms => DrainTimeout}), - drain_connections(DrainTimeout), - ?LOG_NOTICE(#{msg => <<"Stopping listener">>}), - cowboy:stop_listener(nova_listener), + drain_connections(Listeners, DrainTimeout), + ?LOG_NOTICE(#{msg => <<"Stopping listeners">>}), + [cowboy:stop_listener(Listener) || Listener <- Listeners], ok. -drain_connections(Timeout) -> +%% A listener can already be gone if the application was removed at runtime, +%% so neither suspending nor inspecting it may bring the shutdown down. +suspend(Listener) -> + try ranch:suspend_listener(Listener) of + _Result -> ok + catch + _Class:_Reason -> ok + end. + +active_connections(Listener) -> + try ranch:info(Listener) of + #{active_connections := N} -> N + catch + _Class:_Reason -> 0 + end. + +drain_connections(Listeners, Timeout) -> Deadline = erlang:monotonic_time(millisecond) + Timeout, - drain_loop(Deadline). + drain_loop(Listeners, Deadline). -drain_loop(Deadline) -> - case ranch:info(nova_listener) of - #{active_connections := 0} -> +drain_loop(Listeners, Deadline) -> + case lists:sum([active_connections(Listener) || Listener <- Listeners]) of + 0 -> ok; - #{active_connections := N} -> + N -> Now = erlang:monotonic_time(millisecond), case Now >= Deadline of true -> @@ -66,6 +83,6 @@ drain_loop(Deadline) -> ok; false -> timer:sleep(500), - drain_loop(Deadline) + drain_loop(Listeners, Deadline) end end. diff --git a/src/nova_router.erl b/src/nova_router.erl index b3bb82f..904d34e 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -16,6 +16,7 @@ %% API -export([ compile/1, + compile/2, lookup_url/1, lookup_url/2, lookup_url/3, @@ -29,11 +30,15 @@ %% Fetch information about the routing table plugins/0, compiled_apps/0, + compiled_apps/1, %% Modulates the routes-table add_routes/1, add_routes/2, - remove_application/1 + add_routes/3, + remove_application/1, + remove_application/2, + delete_dispatch/1 ]). -include_lib("kernel/include/logger.hrl"). @@ -58,11 +63,24 @@ -define(NOVA_APPS, nova_apps). -define(NOVA_PLUGINS, nova_plugins). +-define(NOVA_DISPATCH, nova_dispatch). + +%% Each Cowboy listener owns a routing table, addressed by a dispatch key. +%% Listeners started by nova_sup:add_application/2 get their own, so two +%% listeners on different ports do not serve each other's routes. The default +%% listener uses nova_dispatch, which is also what every existing caller and +%% every stored dispatch table already uses. +-type dispatch_key() :: term(). +-export_type([dispatch_key/0]). -spec compiled_apps() -> [{App :: atom(), Prefix :: list()}]. compiled_apps() -> + compiled_apps(?NOVA_DISPATCH). + +-spec compiled_apps(DispatchKey :: dispatch_key()) -> [{App :: atom(), Prefix :: list()}]. +compiled_apps(DispatchKey) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - StorageBackend:get(?NOVA_APPS, []). + StorageBackend:get(apps_key(DispatchKey), []). %% TODO! We need to implement a way to get and remove plugins for a path @@ -72,14 +90,25 @@ plugins() -> -spec compile(Apps :: [atom() | {atom(), map()}]) -> nova_routing_trie:trie(). compile(Apps) -> + compile(Apps, ?NOVA_DISPATCH). + +%%-------------------------------------------------------------------- +%% @doc +%% Compile the given applications into the routing table addressed by +%% `DispatchKey', merging into whatever is already stored there. +%% @end +%%-------------------------------------------------------------------- +-spec compile(Apps :: [atom() | {atom(), map()}], DispatchKey :: dispatch_key()) -> + nova_routing_trie:trie(). +compile(Apps, DispatchKey) -> UseStrict = application:get_env(nova, use_strict_routing, false), StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - StoredDispatch = StorageBackend:get(nova_dispatch, + StoredDispatch = StorageBackend:get(DispatchKey, nova_routing_trie:new(#{strict => UseStrict})), - Dispatch = compile(Apps, StoredDispatch, #{}), + Dispatch = compile(Apps, StoredDispatch, #{dispatch_key => DispatchKey}), %% Write the updated dispatch to storage - StorageBackend:put(nova_dispatch, Dispatch), + StorageBackend:put(DispatchKey, Dispatch), Dispatch. -spec execute(Req, Env :: cowboy_middleware:env()) -> {ok, Req, Env0} | {stop, Req} @@ -87,7 +116,7 @@ compile(Apps) -> Env0::cowboy_middleware:env(). execute(Req = #{host := Host, path := Path, method := Method}, Env) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - Dispatch = StorageBackend:get(nova_dispatch), + Dispatch = StorageBackend:get(dispatch_key(Env)), case nova_routing_trie:find(Host, Path, Method, Dispatch) of {error, not_found} -> logger:debug(<<"Path ~p not found for ~p in ~p">>, [Path, Method, Host]), @@ -151,7 +180,7 @@ lookup_url(Host, Path) -> Method :: nova_routing_trie:comparator()) -> lookup_result(). lookup_url(Host, Path, Method) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - Dispatch = StorageBackend:get(nova_dispatch), + Dispatch = StorageBackend:get(?NOVA_DISPATCH), lookup_url(Host, Path, Method, Dispatch). -spec lookup_url(Host :: binary() | atom(), Path :: nova_routing_trie:path(), @@ -183,42 +212,52 @@ add_routes(App) -> %% @end %%-------------------------------------------------------------------- -spec add_routes(App :: atom(), Routes :: [map()] | map()) -> ok. -add_routes(_App, []) -> +add_routes(App, Routes) -> + add_routes(App, Routes, ?NOVA_DISPATCH). + +%%-------------------------------------------------------------------- +%% @doc +%% As add_routes/2, but against the routing table addressed by `DispatchKey'. +%% @end +%%-------------------------------------------------------------------- +-spec add_routes(App :: atom(), Routes :: [map()] | map(), DispatchKey :: dispatch_key()) -> ok. +add_routes(_App, [], _DispatchKey) -> ok; -add_routes(App, Routes) when is_map(Routes) -> - add_routes(App, [Routes]); -add_routes(App, [Routes|Tl]) when is_list(Routes) -> +add_routes(App, Routes, DispatchKey) when is_map(Routes) -> + add_routes(App, [Routes], DispatchKey); +add_routes(App, [Routes|Tl], DispatchKey) when is_list(Routes) -> %% A list of route-lists, as produced by a router that returns several %% groups. Each group is compiled on its own. - ok = insert_route_maps(App, Routes), - add_routes(App, Tl); -add_routes(App, [RouteInfo|_Tl] = Routes) when is_map(RouteInfo) -> - insert_route_maps(App, Routes); -add_routes(App, Routes) -> + ok = insert_route_maps(App, Routes, DispatchKey), + add_routes(App, Tl, DispatchKey); +add_routes(App, [RouteInfo|_Tl] = Routes, DispatchKey) when is_map(RouteInfo) -> + insert_route_maps(App, Routes, DispatchKey); +add_routes(App, Routes, _DispatchKey) -> ?LOG_ERROR(#{reason => <<"Invalid routes structure">>, app => App, routes => Routes}), throw({error, {invalid_routes, App, Routes}}). -insert_route_maps(App, Routes) -> +insert_route_maps(App, Routes, DispatchKey) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - Dispatch = StorageBackend:get(nova_dispatch), + Dispatch = StorageBackend:get(DispatchKey), %% Take out the prefix for the app and store it in the persistent store - CompiledApps = StorageBackend:get(?NOVA_APPS, []), + AppsKey = apps_key(DispatchKey), + CompiledApps = StorageBackend:get(AppsKey, []), CompiledApps0 = case lists:keyfind(App, 1, CompiledApps) of - false -> [{App, "/"}|CompiledApps]; + false -> CompiledApps ++ [{App, "/"}]; _StoredApp -> CompiledApps end, %% Routes added at runtime replace any route already registered on the %% same path and method, which is what the routing guide promises. - Options = #{app => App, router_file => undefined, + Options = #{app => App, router_file => undefined, dispatch_key => DispatchKey, insert_opts => #{on_duplicate => overwrite}}, {ok, Dispatch1, _Options0} = compile_paths(Routes, Dispatch, Options), - StorageBackend:put(?NOVA_APPS, CompiledApps0), - StorageBackend:put(nova_dispatch, Dispatch1), + StorageBackend:put(AppsKey, CompiledApps0), + StorageBackend:put(DispatchKey, Dispatch1), ok. @@ -228,16 +267,27 @@ insert_route_maps(App, Routes) -> %% @end %%-------------------------------------------------------------------- -spec remove_application(Application :: atom()) -> ok. -remove_application(Application) when is_atom(Application) -> +remove_application(Application) -> + remove_application(Application, ?NOVA_DISPATCH). + +%%-------------------------------------------------------------------- +%% @doc +%% As remove_application/1, but against the routing table addressed by +%% `DispatchKey'. +%% @end +%%-------------------------------------------------------------------- +-spec remove_application(Application :: atom(), DispatchKey :: dispatch_key()) -> ok. +remove_application(Application, DispatchKey) when is_atom(Application) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - Dispatch = StorageBackend:get(nova_dispatch), + Dispatch = StorageBackend:get(DispatchKey), {ok, Dispatch0} = nova_routing_trie:foldl(Dispatch, fun(Routes) -> [Route || Route <- Routes, route_app(Route) =/= Application] end), - StorageBackend:put(nova_dispatch, Dispatch0), - StorageBackend:put(?NOVA_APPS, lists:keydelete(Application, 1, StorageBackend:get(?NOVA_APPS, []))), + AppsKey = apps_key(DispatchKey), + StorageBackend:put(DispatchKey, Dispatch0), + StorageBackend:put(AppsKey, lists:keydelete(Application, 1, StorageBackend:get(AppsKey, []))), nova:set_env(apps, lists:keydelete(Application, 1, nova:get_env(apps, []))), ok. @@ -248,6 +298,28 @@ route_app({_Host, _Path, _Method, #cowboy_handler_value{app = App}}) -> App; route_app(_Route) -> undefined. +%%-------------------------------------------------------------------- +%% @doc +%% Forget a routing table entirely. Called when the listener that owned it is +%% stopped, so its routes and compiled-application list do not outlive it. +%% The default table belongs to the bootstrap listener and is never deleted. +%% @end +%%-------------------------------------------------------------------- +-spec delete_dispatch(DispatchKey :: dispatch_key()) -> ok. +delete_dispatch(?NOVA_DISPATCH) -> + ok; +delete_dispatch(DispatchKey) -> + case application:get_env(nova, dispatch_backend, persistent_term) of + persistent_term -> + persistent_term:erase(DispatchKey), + persistent_term:erase(apps_key(DispatchKey)), + ok; + _Backend -> + %% A custom backend has no erase in its contract; leave it to + %% decide its own lifecycle. + ok + end. + %%%%%%%%%%%%%%%%%%%%%%%% %% INTERNAL FUNCTIONS %% %%%%%%%%%%%%%%%%%%%%%%%% @@ -295,11 +367,11 @@ compile([App|Tl], Dispatch, Options) -> %% Take out the prefix for the app and store it in the persistent store StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - CompiledApps = StorageBackend:get(?NOVA_APPS, []), + CompiledApps = StorageBackend:get(apps_key(maps:get(dispatch_key, Options, ?NOVA_DISPATCH)), []), CompiledApps0 = lists:keystore(App, 1, CompiledApps, {App, maps:get(prefix, Options, "/")}), - StorageBackend:put(?NOVA_APPS, CompiledApps0), + StorageBackend:put(apps_key(maps:get(dispatch_key, Options, ?NOVA_DISPATCH)), CompiledApps0), compile(Tl, Dispatch1, Options). @@ -475,9 +547,7 @@ render_status_page(StatusCode, Req) -> -spec render_status_page(StatusCode :: integer(), Data :: map(), Req :: cowboy_req:req()) -> {ok, Req0 :: cowboy_req:req(), Env :: map()}. render_status_page(StatusCode, Data, Req) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - Dispatch = StorageBackend:get(nova_dispatch), - render_status_page('_', StatusCode, Data, Req, #{dispatch => Dispatch}). + render_status_page('_', StatusCode, Data, Req, #{}). -spec render_status_page(Host :: binary() | atom(), StatusCode :: integer(), @@ -486,7 +556,7 @@ render_status_page(StatusCode, Data, Req) -> Env :: map()) -> {ok, Req0 :: cowboy_req:req(), Env :: map()}. render_status_page(Host, StatusCode, Data, Req, Env) -> StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - Dispatch = StorageBackend:get(nova_dispatch), + Dispatch = StorageBackend:get(dispatch_key(Env)), {Req0, Env0} = case nova_routing_trie:find(Host, StatusCode, '_', Dispatch) of {error, _} -> @@ -514,6 +584,15 @@ render_status_page(Host, StatusCode, Data, Req, Env) -> insert_opts(T) -> maps:get(insert_opts, T, #{}). +%% The listener's dispatch key, defaulting to the one the bootstrap listener +%% uses so an Env built before multi-listener support still resolves. +dispatch_key(Env) -> + maps:get(nova_dispatch_key, Env, ?NOVA_DISPATCH). + +%% Each dispatch table keeps its own list of compiled applications. +apps_key(?NOVA_DISPATCH) -> ?NOVA_APPS; +apps_key(DispatchKey) -> {?NOVA_APPS, DispatchKey}. + insert_methods([], _Host, _Path, _Value, Tree, _Options, _ToComparator) -> Tree; insert_methods([Method|Tl], Host, Path, Value, Tree, Options, ToComparator) -> diff --git a/src/nova_sup.erl b/src/nova_sup.erl index 8635bdb..7c1d8a7 100644 --- a/src/nova_sup.erl +++ b/src/nova_sup.erl @@ -1,6 +1,16 @@ %%% @author Niclas Axelsson %%% @doc -%%% Nova supervisor +%%% Nova supervisor. +%%% +%%% Owns the Cowboy listeners. The bootstrap application gets one at startup +%%% from the `cowboy_configuration' key, and further applications can be +%%% started and stopped at runtime with {@link add_application/2} and +%%% {@link remove_application/1}. +%%% +%%% Each listener owns its own routing table, so an application started on a +%%% second port serves only its own routes. Applications added to a listener +%%% that is already bound to the same host and port share that listener's +%%% table instead. %%% @end -module(nova_sup). @@ -8,7 +18,13 @@ -behaviour(supervisor). %% API --export([start_link/0]). +-export([ + start_link/0, + add_application/2, + remove_application/1, + get_started_applications/0, + listeners/0 + ]). %% Supervisor callbacks -export([init/1]). @@ -20,9 +36,18 @@ -define(NOVA_LISTENER, nova_listener). -define(NOVA_STD_PORT, 8080). -define(NOVA_STD_SSL_PORT, 8443). +-define(NOVA_LISTENERS_TABLE, nova_listeners). -type nova_app() :: atom() | {atom(), map()}. +-record(nova_listener, { + ref :: ranch:ref(), + apps = [] :: [atom()], + host :: inet:ip_address() | string(), + port :: inet:port_number(), + dispatch_key :: nova_router:dispatch_key(), + tls = false :: boolean() + }). %%%=================================================================== %%% API functions @@ -38,6 +63,70 @@ start_link() -> supervisor:start_link({local, ?SERVER}, ?MODULE, []). +%%-------------------------------------------------------------------- +%% @doc +%% Start a Nova application at runtime. +%% +%% `Configuration' takes the same shape as the `cowboy_configuration' +%% environment key. If a listener is already bound to the requested host and +%% port, the application's routes are added to that listener's routing table. +%% Otherwise a new listener is started with a routing table of its own. +%% @end +%%-------------------------------------------------------------------- +-spec add_application(App :: atom(), Configuration :: map()) -> + {ok, App :: atom(), Host :: inet:ip_address() | string(), Port :: inet:port_number()} | + {error, Reason :: any()}. +add_application(App, Configuration) -> + Host = maps:get(ip, Configuration, {0, 0, 0, 0}), + Port = effective_port(Configuration), + case find_listener(Host, Port) of + {ok, Listener} -> + attach_application(App, Listener); + error -> + start_listener(App, Host, Port, Configuration) + end. + +%%-------------------------------------------------------------------- +%% @doc +%% Stop a Nova application. Its routes are removed from the listener serving +%% it, and the listener itself is stopped once no applications are left on it. +%% @end +%%-------------------------------------------------------------------- +-spec remove_application(App :: atom()) -> ok | {error, not_found}. +remove_application(App) -> + case [L || L = #nova_listener{apps = Apps} <- all_listeners(), lists:member(App, Apps)] of + [] -> + ?LOG_ERROR(#{msg => <<"Application not found">>, app => App}), + {error, not_found}; + Listeners -> + [detach_application(App, Listener) || Listener <- Listeners], + ok + end. + +%%-------------------------------------------------------------------- +%% @doc +%% Every started Nova application, with the listener serving it. +%% @end +%%-------------------------------------------------------------------- +-spec get_started_applications() -> [#{app := atom(), + host := inet:ip_address() | string(), + port := inet:port_number(), + listener := ranch:ref()}]. +get_started_applications() -> + [#{app => App, host => Host, port => Port, listener => Ref} + || #nova_listener{ref = Ref, apps = Apps, host = Host, port = Port} <- all_listeners(), + App <- Apps]. + +%%-------------------------------------------------------------------- +%% @doc +%% Every Cowboy listener Nova has started. Used by the graceful shutdown in +%% nova_app, which has to drain all of them and not just the first. +%% @end +%%-------------------------------------------------------------------- +-spec listeners() -> [ranch:ref()]. +listeners() -> + [Ref || #nova_listener{ref = Ref} <- all_listeners()]. + %%%=================================================================== %%% Supervisor callbacks %%%=================================================================== @@ -64,6 +153,8 @@ init([]) -> ?LOG_NOTICE(#{msg => <<"Starting nova">>, environment => Environment}), + ensure_listener_table(), + Configuration = application:get_env(nova, cowboy_configuration, #{}), SessionManager = application:get_env(nova, session_manager, nova_session_ets), @@ -76,7 +167,7 @@ init([]) -> %% try to ensure callback module is loaded first ExportedFuns = SessionManager:module_info(exports), - + Children = case proplists:get_value(start_link, ExportedFuns) of 0 -> [child(SessionManager, SessionManager) | Children0]; @@ -105,6 +196,30 @@ child(Id, Type, Mod) -> child(Id, Mod) -> child(Id, worker, Mod). +%% The table survives a nova_sup restart, so creating it has to be idempotent. +%% It is public because add_application/2 and remove_application/1 are called +%% by whoever wants the application started, not by the supervisor process. +ensure_listener_table() -> + case ets:whereis(?NOVA_LISTENERS_TABLE) of + undefined -> + ets:new(?NOVA_LISTENERS_TABLE, + [named_table, public, set, {keypos, #nova_listener.ref}]); + _Tid -> + ?NOVA_LISTENERS_TABLE + end. + +all_listeners() -> + case ets:whereis(?NOVA_LISTENERS_TABLE) of + undefined -> []; + _Tid -> ets:tab2list(?NOVA_LISTENERS_TABLE) + end. + +find_listener(Host, Port) -> + case [L || L = #nova_listener{host = H, port = P} <- all_listeners(), H =:= Host, P =:= Port] of + [Listener | _] -> {ok, Listener}; + [] -> error + end. + setup_cowboy(Configuration) -> case start_cowboy(Configuration) of {ok, App, Host, Port} -> @@ -121,30 +236,11 @@ setup_cowboy(Configuration) -> end. -spec start_cowboy(Configuration :: map()) -> - {ok, BootstrapApp :: atom(), Host :: string() | {integer(), integer(), integer(), integer()}, - Port :: integer()} | {error, Reason :: any()}. + {ok, BootstrapApp :: atom(), Host :: inet:ip_address() | string(), + Port :: inet:port_number()} | {error, Reason :: any()}. start_cowboy(Configuration) -> - Middlewares = [ - nova_router, %% Lookup routes - nova_plugin_handler, %% Handle pre-request plugins - nova_security_handler, %% Handle security - nova_handler, %% Controller - nova_plugin_handler %% Handle post-request plugins - ], - StreamH = [nova_stream_h, - cowboy_compress_h, - cowboy_stream_h], - StreamHandlers = maps:get(stream_handlers, Configuration, StreamH), - MiddlewareHandlers = maps:get(middleware_handlers, Configuration, Middlewares), - Options = maps:get(options, Configuration, #{compress => true}), - - %% Build the options map - CowboyOptions1 = Options#{middlewares => MiddlewareHandlers, - stream_handlers => StreamHandlers}, - BootstrapApp = application:get_env(nova, bootstrap_application, undefined), - %% Compile the routes Dispatch = case BootstrapApp of undefined -> @@ -158,68 +254,162 @@ start_cowboy(Configuration) -> nova_router:compile(resolve_nova_apps([App | ExtraApps] ++ [nova])) end, - CowboyOptions2 = - case application:get_env(nova, use_persistent_term, true) of - true -> - CowboyOptions1; - _ -> - CowboyOptions1#{env => #{dispatch => Dispatch}} - end, + Host = maps:get(ip, Configuration, {0, 0, 0, 0}), + Port = effective_port(Configuration), + + CowboyOptions = cowboy_options(Configuration, nova_dispatch, Dispatch), + + case bind(?NOVA_LISTENER, Host, Port, Configuration, CowboyOptions) of + {ok, Tls} -> + register_listener(#nova_listener{ref = ?NOVA_LISTENER, + apps = [BootstrapApp], + host = Host, + port = Port, + dispatch_key = nova_dispatch, + tls = Tls}), + {ok, BootstrapApp, Host, Port}; + {error, Reason} -> + {error, Reason} + end. + +%%-------------------------------------------------------------------- +%% Runtime application lifecycle +%%-------------------------------------------------------------------- + +start_listener(App, Host, Port, Configuration) -> + Ref = {?NOVA_LISTENER, App, Port}, + DispatchKey = {nova_dispatch, App, Port}, + ExtraApps = application:get_env(App, nova_apps, []), + Dispatch = nova_router:compile(resolve_nova_apps([App | ExtraApps] ++ [nova]), DispatchKey), + CowboyOptions = cowboy_options(Configuration, DispatchKey, Dispatch), + case bind(Ref, Host, Port, Configuration, CowboyOptions) of + {ok, Tls} -> + register_listener(#nova_listener{ref = Ref, + apps = [App], + host = Host, + port = Port, + dispatch_key = DispatchKey, + tls = Tls}), + ?LOG_NOTICE(#{msg => <<"Started Nova application on a new listener">>, + app => App, port => Port, listener => Ref}), + {ok, App, Host, Port}; + {error, Reason} -> + ?LOG_ERROR(#{msg => <<"Could not start listener for application">>, + app => App, port => Port, reason => Reason}), + {error, Reason} + end. + +attach_application(App, Listener = #nova_listener{ref = Ref, apps = Apps, host = Host, + port = Port, dispatch_key = DispatchKey}) -> + case lists:member(App, Apps) of + true -> + {error, {already_started, App}}; + false -> + ExtraApps = application:get_env(App, nova_apps, []), + nova_router:compile(resolve_nova_apps([App | ExtraApps]), DispatchKey), + register_listener(Listener#nova_listener{apps = Apps ++ [App]}), + ?LOG_NOTICE(#{msg => <<"Added Nova application to an existing listener">>, + app => App, port => Port, listener => Ref}), + {ok, App, Host, Port} + end. + +detach_application(App, #nova_listener{ref = Ref, apps = Apps, dispatch_key = DispatchKey} = Listener) -> + ok = nova_router:remove_application(App, DispatchKey), + case lists:delete(App, Apps) of + [] -> + ?LOG_NOTICE(#{msg => <<"Stopping cowboy listener">>, app => App, listener => Ref}), + case cowboy:stop_listener(Ref) of + ok -> + ok; + {error, Reason} -> + ?LOG_ERROR(#{msg => <<"Could not stop cowboy listener">>, + listener => Ref, reason => Reason}) + end, + ets:delete(?NOVA_LISTENERS_TABLE, Ref), + ok = nova_router:delete_dispatch(DispatchKey), + ok; + Remaining -> + register_listener(Listener#nova_listener{apps = Remaining}), + ok + end. - Host = maps:get(ip, Configuration, { 0, 0, 0, 0}), +register_listener(Listener) -> + ensure_listener_table(), + true = ets:insert(?NOVA_LISTENERS_TABLE, Listener), + ok. +%%-------------------------------------------------------------------- +%% Cowboy plumbing +%%-------------------------------------------------------------------- + +%% The port a configuration actually binds. Only reading the `port' key made +%% a TLS listener on ssl_port invisible to the already-bound check. +effective_port(Configuration) -> + case maps:get(use_ssl, Configuration, false) of + false -> maps:get(port, Configuration, ?NOVA_STD_PORT); + _ -> maps:get(ssl_port, Configuration, ?NOVA_STD_SSL_PORT) + end. + +cowboy_options(Configuration, DispatchKey, Dispatch) -> + Middlewares = [ + nova_router, %% Lookup routes + nova_plugin_handler, %% Handle pre-request plugins + nova_security_handler, %% Handle security + nova_handler, %% Controller + nova_plugin_handler %% Handle post-request plugins + ], + StreamH = [nova_stream_h, + cowboy_compress_h, + cowboy_stream_h], + StreamHandlers = maps:get(stream_handlers, Configuration, StreamH), + MiddlewareHandlers = maps:get(middleware_handlers, Configuration, Middlewares), + Options = maps:get(options, Configuration, #{compress => true}), + + %% nova_router reads its routing table out of the Env, so a listener always + %% carries the key of the table it serves. + Env0 = maps:get(env, Options, #{}), + Env = Env0#{nova_dispatch_key => DispatchKey}, + + CowboyOptions = Options#{middlewares => MiddlewareHandlers, + stream_handlers => StreamHandlers, + env => Env}, + + case application:get_env(nova, use_persistent_term, true) of + true -> CowboyOptions; + _ -> CowboyOptions#{env => Env#{dispatch => Dispatch}} + end. + +%% Returns {ok, IsTls} so the caller can record how the listener was bound. +bind(Ref, Host, Port, Configuration, CowboyOptions) -> case maps:get(use_ssl, Configuration, false) of false -> - Port = maps:get(port, Configuration, ?NOVA_STD_PORT), - case cowboy:start_clear( - ?NOVA_LISTENER, - [{port, Port}, - {ip, Host}], - CowboyOptions2) of - {ok, _Pid} -> - {ok, BootstrapApp, Host, Port}; - Error -> - Error + case cowboy:start_clear(Ref, [{port, Port}, {ip, Host}], CowboyOptions) of + {ok, _Pid} -> {ok, false}; + {error, Reason} -> {error, Reason} end; _ -> - case maps:get(ca_cert, Configuration, undefined) of - undefined -> - Port = maps:get(ssl_port, Configuration, ?NOVA_STD_SSL_PORT), - SSLOptions = maps:get(ssl_options, Configuration, #{}), - TransportOpts = maps:put(port, Port, SSLOptions), - TransportOpts1 = maps:put(ip, Host, TransportOpts), - - case cowboy:start_tls( - ?NOVA_LISTENER, maps:to_list(TransportOpts1), CowboyOptions2) of - {ok, _Pid} -> - ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => Port}), - {ok, BootstrapApp, Host, Port}; - Error -> - ?LOG_ERROR(#{msg => <<"Could not start cowboy with SSL">>, reason => Error}), - Error - end; - CACert -> - Cert = maps:get(cert, Configuration), - Port = maps:get(ssl_port, Configuration, ?NOVA_STD_SSL_PORT), - ?LOG_DEPRECATED(<<"0.10.3">>, <<"Use of use_ssl is deprecated, use ssl instead">>), - case cowboy:start_tls( - ?NOVA_LISTENER, [ - {port, Port}, - {ip, Host}, - {certfile, Cert}, - {cacertfile, CACert} - ], - CowboyOptions2) of - {ok, _Pid} -> - ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => Port}), - {ok, BootstrapApp, Host, Port}; - Error -> - Error - end - end + bind_tls(Ref, Host, Port, Configuration, CowboyOptions) end. - +bind_tls(Ref, Host, Port, Configuration, CowboyOptions) -> + TransportOpts = + case maps:get(ca_cert, Configuration, undefined) of + undefined -> + SSLOptions = maps:get(ssl_options, Configuration, #{}), + maps:to_list(SSLOptions#{port => Port, ip => Host}); + CACert -> + Cert = maps:get(cert, Configuration), + ?LOG_DEPRECATED(<<"0.10.3">>, <<"Use of ca_cert/cert is deprecated, use ssl_options instead">>), + [{port, Port}, {ip, Host}, {certfile, Cert}, {cacertfile, CACert}] + end, + case cowboy:start_tls(Ref, TransportOpts, CowboyOptions) of + {ok, _Pid} -> + ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => Port}), + {ok, true}; + {error, Reason} -> + ?LOG_ERROR(#{msg => <<"Could not start cowboy with SSL">>, reason => Reason}), + {error, Reason} + end. get_version(Application) -> case lists:keyfind(Application, 1, application:loaded_applications()) of diff --git a/test/nova_multi_listener_SUITE.erl b/test/nova_multi_listener_SUITE.erl new file mode 100644 index 0000000..b168207 --- /dev/null +++ b/test/nova_multi_listener_SUITE.erl @@ -0,0 +1,149 @@ +%%% Multi-listener suite. +%%% +%%% Boots the bootstrap application on one port, then starts a second +%%% application on a second port at runtime and checks that the two listeners +%%% serve their own routes and nothing else. Also covers attaching a second +%%% application to an already-bound port, and tearing both back down. +-module(nova_multi_listener_SUITE). + +-compile([export_all, nowarn_export_all]). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +all() -> + [ + bootstrap_listener_is_registered, + second_application_gets_its_own_port, + listeners_do_not_serve_each_others_routes, + second_application_on_a_bound_port_shares_the_listener, + adding_a_started_application_again_is_an_error, + removing_an_application_stops_only_its_listener, + removing_an_unknown_application_is_an_error + ]. + +init_per_suite(Config) -> + Port = free_port(), + application:load(nova), + application:set_env(nova, bootstrap_application, nova_test_app), + application:set_env(nova, cowboy_configuration, #{port => Port}), + application:set_env(nova, environment, test), + application:set_env(nova, plugins, []), + {ok, _Started} = application:ensure_all_started(nova_test_app), + {ok, _} = application:ensure_all_started(inets), + [{port, Port} | Config]. + +end_per_suite(_Config) -> + application:stop(nova_test_app), + application:stop(nova), + ok. + +%%==================================================================== +%% Cases +%%==================================================================== + +bootstrap_listener_is_registered(Config) -> + Port = ?config(port, Config), + Started = nova_sup:get_started_applications(), + ?assertMatch([_ | _], [S || S = #{app := nova_test_app, port := P} <- Started, P =:= Port]), + ?assert(lists:member(nova_listener, nova_sup:listeners())). + +second_application_gets_its_own_port(_Config) -> + Port = free_port(), + {ok, nova_test_sub_app, _Host, Port} = + nova_sup:add_application(nova_test_sub_app, #{port => Port}), + try + {200, Body} = get(Port, "/hello"), + ?assertEqual(#{<<"app">> => <<"nova_test_sub_app">>}, json(Body)) + after + nova_sup:remove_application(nova_test_sub_app) + end. + +%% The point of binding a second port: each listener has its own routing +%% table, so neither answers for the other. +listeners_do_not_serve_each_others_routes(Config) -> + BootstrapPort = ?config(port, Config), + Port = free_port(), + {ok, _App, _Host, Port} = nova_sup:add_application(nova_test_sub_app, #{port => Port}), + try + %% The second listener serves its own route ... + {200, _} = get(Port, "/hello"), + %% ... but not the bootstrap application's. + {404, _} = get(Port, "/json"), + %% And the bootstrap listener is unchanged. + {200, _} = get(BootstrapPort, "/json"), + {404, _} = get(BootstrapPort, "/hello") + after + nova_sup:remove_application(nova_test_sub_app) + end. + +%% Adding an application to a host and port that is already bound must reuse +%% that listener rather than trying to bind the port twice. +second_application_on_a_bound_port_shares_the_listener(Config) -> + BootstrapPort = ?config(port, Config), + Before = length(nova_sup:listeners()), + {ok, nova_test_sub_app, _Host, BootstrapPort} = + nova_sup:add_application(nova_test_sub_app, #{port => BootstrapPort}), + try + ?assertEqual(Before, length(nova_sup:listeners())), + %% Both applications now answer on the same listener. + {200, _} = get(BootstrapPort, "/hello"), + {200, _} = get(BootstrapPort, "/json") + after + nova_sup:remove_application(nova_test_sub_app) + end, + %% Removing the attached application leaves the listener and the other + %% application's routes alone. + ?assertEqual(Before, length(nova_sup:listeners())), + {404, _} = get(BootstrapPort, "/hello"), + {200, _} = get(BootstrapPort, "/json"). + +adding_a_started_application_again_is_an_error(Config) -> + BootstrapPort = ?config(port, Config), + ?assertEqual({error, {already_started, nova_test_app}}, + nova_sup:add_application(nova_test_app, #{port => BootstrapPort})). + +removing_an_application_stops_only_its_listener(Config) -> + BootstrapPort = ?config(port, Config), + Port = free_port(), + {ok, _App, _Host, Port} = nova_sup:add_application(nova_test_sub_app, #{port => Port}), + ?assertEqual(2, length(nova_sup:listeners())), + + ok = nova_sup:remove_application(nova_test_sub_app), + ?assertEqual([nova_listener], nova_sup:listeners()), + + %% The port is released, and the bootstrap listener still answers. + ?assertEqual({error, econnrefused}, connect(Port)), + {200, _} = get(BootstrapPort, "/json"). + +removing_an_unknown_application_is_an_error(_Config) -> + ?assertEqual({error, not_found}, nova_sup:remove_application(no_such_app)). + +%%==================================================================== +%% Helpers +%%==================================================================== + +get(Port, Path) -> + Url = "http://localhost:" ++ integer_to_list(Port) ++ Path, + {ok, {{_Version, Status, _Reason}, _Headers, Body}} = + httpc:request(get, {Url, []}, [{autoredirect, false}], [{body_format, binary}]), + {Status, Body}. + +json(Body) -> + {ok, Decoded} = thoas:decode(Body), + Decoded. + +connect(Port) -> + case gen_tcp:connect("localhost", Port, [{active, false}], 1000) of + {ok, Socket} -> + gen_tcp:close(Socket), + ok; + {error, Reason} -> + {error, Reason} + end. + +free_port() -> + {ok, Socket} = gen_tcp:listen(0, [{reuseaddr, true}]), + {ok, Port} = inet:port(Socket), + ok = gen_tcp:close(Socket), + Port. From 856bea6b97e3d138d9660982c94dfaa245c74889 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 10 Aug 2026 12:15:33 +0200 Subject: [PATCH 21/21] docs: document the routing rewrite and the multi-app lifecycle guides/routing.md gains the precedence rules, which are worth stating now that they are deterministic: literal, then bindings in name order, then the catch-all, with backtracking, and an exact method beating '_'. Also documents plugin_strategy and override_secure, both of which the branch added with no mention anywhere, and corrects add_route/2 to add_routes/2 - that function has never existed under the documented name. guides/multi-app.md is rewritten. It had two unterminated code fences that swallowed the sys.config example, a table row with unescaped pipes, and it described add_application/2 and remove_application/1 doing things the code did not do. It now covers what actually happens, including that a listener on a new port gets its own routing table and one on a bound port is shared. guides/deprecations.md records the routing_tree removal and the behaviour changes worth knowing about on upgrade: matching now backtracks, every binding at a given depth is reachable, use_strict_routing takes effect for the first time, and an application's own status-code routes now win over Nova's defaults. guides/graceful-shutdown.md no longer names a single listener, and deprecations.md is added to the ex_doc extras so it publishes. Also in this pass: - dispatch_backend is narrowed to a module once rather than being called as whatever term the environment holds, which also takes nova_router below its previous eqwalizer error count. - nova_routing_trie no longer treats unicode:characters_to_binary/1's error tuple as a binary, which would have put an unroutable route in the table. The module is eqwalizer-clean. - inet:ntoa/1 is only called on an address tuple. ranch also accepts a hostname for ip, which would have crashed the startup log line. - The CT suites export their cases explicitly instead of export_all, so elp lint stops reading their helpers as unreachable tests. --- guides/deprecations.md | 45 ++++++++++++ guides/graceful-shutdown.md | 4 +- guides/multi-app.md | 107 ++++++++++++++++++++++------- guides/routing.md | 83 ++++++++++++++++++++-- rebar.config | 1 + src/nova_router.erl | 33 ++++++--- src/nova_routing_trie.erl | 33 ++++++--- src/nova_sup.erl | 7 +- test/nova_full_app_SUITE.erl | 30 +++++++- test/nova_multi_listener_SUITE.erl | 12 +++- 10 files changed, 302 insertions(+), 53 deletions(-) diff --git a/guides/deprecations.md b/guides/deprecations.md index bf17e96..6598c23 100644 --- a/guides/deprecations.md +++ b/guides/deprecations.md @@ -9,3 +9,48 @@ This is a list of the things that are currently deprecated in Nova. - The old format with `{Module, Function}` is deprecated in favor of `fun Module:function/1`. This is a breaking change, but it is a good time to do it now before we release 1.0.0. The old format will be removed in 1.0.0. This goes for all occurrences of `{Module, Function}` in Nova. + + +## Unreleased + +- `routing_tree` is no longer a dependency. Nova's dispatch table is now built + by the in-tree `nova_routing_trie`. If you introspected the routing table by + including `routing_tree.hrl` and matching on `#host_tree{}`, `#routing_tree{}`, + `#node{}` or `#node_comp{}`, those records are gone. Use + `nova_routing_trie:routes/1`, which returns + `[{Host, Path, Method, HandlerValue}]`; the trie itself is opaque. + + Nothing changes for applications that only declare routes and let Nova serve + them. + +### Behaviour changes to be aware of + +- **Route matching backtracks.** A request is now matched against every route + that could serve it, so `/a/:x/c` matches `/a/b/c` even when a `/a/b/d` route + exists. Previously matching committed to the first sibling it found and gave + up, returning a 404. Routes that were unreachable before may start being + reached. + +- **Every binding at the same depth is reachable.** `/p/:id/picture` and + `/p/:user_id/name` both work. Previously only whichever one happened to be + visited first was reachable, and which one that was depended on insertion + order. + +- **`use_strict_routing` now takes effect.** It never reached the routing table + before, so it has been a no-op. An application with genuinely conflicting + routes and `use_strict_routing` set to `true` will now refuse to start. + Conflicts are reported with both paths and the method. + +- **An application's own status-code routes now win.** Nova's routes are + compiled last, so a `{404, fun my_controller:not_found/1, #{}}` entry in your + router replaces Nova's default error page. Previously Nova's was registered + first and yours was silently ignored. + +- **Plugins.** A route entry that declares `plugins` uses exactly those, and one + that does not uses the globally configured ones - unchanged. The new + `plugin_strategy` option lets you combine both; see the + [routing guide](routing.md). + +- **`nova_router:lookup_url/1,2,3`** keep their return shapes, including + `{error, comparator_not_found, AllowedMethods}` for a path that exists but + does not accept the method. diff --git a/guides/graceful-shutdown.md b/guides/graceful-shutdown.md index 1ce7462..1470304 100644 --- a/guides/graceful-shutdown.md +++ b/guides/graceful-shutdown.md @@ -73,8 +73,8 @@ If you use readiness probes, your health endpoint should reflect the application Nova implements graceful shutdown in `nova_app:prep_stop/1`, which is called by OTP before the supervision tree is terminated. The sequence is: 1. **Delay** — Sleep for `shutdown_delay` milliseconds. During this time, the listener is still active and serving requests normally. This covers the load balancer propagation window. -2. **Suspend** — Call `ranch:suspend_listener(nova_listener)` to stop accepting new TCP connections. Existing connections continue to be served. +2. **Suspend** — Call `ranch:suspend_listener/1` on every listener Nova has started, so no new TCP connections are accepted. Existing connections continue to be served. 3. **Drain** — Poll `ranch:info/1` every 500ms until active connections reach zero or `shutdown_drain_timeout` is exceeded. -4. **Stop** — Call `cowboy:stop_listener(nova_listener)` to fully shut down the listener. +4. **Stop** — Call `cowboy:stop_listener/1` on every listener to fully shut them down. After `prep_stop` returns, OTP proceeds with the normal supervision tree shutdown. diff --git a/guides/multi-app.md b/guides/multi-app.md index b858b53..554035c 100644 --- a/guides/multi-app.md +++ b/guides/multi-app.md @@ -1,45 +1,104 @@ -# Including other nova applications +# Running several Nova applications -Nova is built to support inclusion of other applications built with Nova. To include an application you first need to include it in the `rebar.config` as a dependency. Then add the `nova_apps` option in your application-configuration. `nova_apps` should contain a list of atoms or two-tuples (For defining options). +Nova can serve more than one Nova application from the same node. An +application can be mounted into another one's listener at startup, or started +and stopped on its own listener at runtime. -## Configuration options +## Including another application at startup -There's currently two different options available and they works in the same way in the routing-module. +Add the application as a dependency in `rebar.config`, then list it under the +`nova_apps` key in your own application's configuration. `nova_apps` takes +application names, or `{Name, Options}` two-tuples when you want to configure +how it is mounted. -| Key | Value | Description | +Included applications are resolved depth-first, so an application that +declares `nova_apps` of its own has those registered too. + +### Options + +| Key | Value | Description | |---|---|---| -| prefix | string | Defines if the applications urls should be prefixed | -| secure | false | {Mod, Fun} | Tells if the application should be secured | +| `prefix` | `string()` | Mount the application's routes under this path | +| `secure` | `false` \| `fun/1` | Security callback for the application's routes | +| `override_secure` | `false` \| `fun/1` | Replace the security callback the application declares for itself | +| `plugin_strategy` | see the [routing guide](routing.md) | How the application's route-local plugins combine with the global ones | -## Example +### Example *rebar.config*: -``` -... +```erlang {deps, [ - {another_nova_app, "1.0.0"}, - ] -... - + {another_nova_app, "1.0.0"} + ]}. +``` -*sys.config* +*sys.config*: -``` -... +```erlang {my_nova_app, [ - {nova_apps, [{another_nova_app, #{prefix => "/another"}}]} - ]} -... + {nova_apps, [{another_nova_app, #{prefix => "/another"}}]} + ]}. +``` + +`another_nova_app` now shares the listener, and the routing table, of +`my_nova_app`. Its routes answer under `/another`. + +## Starting an application at runtime + +`nova_sup:add_application/2` starts a Nova application while the node is +running. The second argument takes the same shape as the `cowboy_configuration` +environment key. + +```erlang +{ok, App, Host, Port} = nova_sup:add_application(my_other_app, #{port => 8081}). ``` -## Pragmatically starting other nova applications +What happens depends on whether the host and port are already bound: -### Starting an application +- **A free port.** A new Cowboy listener is started with a routing table of its + own, holding that application, anything in its `nova_apps`, and Nova's own + error pages. The listener serves only those routes. +- **A port Nova already listens on.** The application's routes are added to + that listener's existing routing table, and the two applications are served + side by side. -You can also start other nova applications pragmatically by calling `nova_sup:add_application/2` to add another nova application to your supervision tree. The routes will automatically be added to the routing-module. +Starting an application that is already running returns +`{error, {already_started, App}}`. +Because each listener has its own routing table, two applications on two ports +do not serve each other's routes. That is the point of binding a second port: +an admin interface on `8081` is not reachable on the public `8080` just +because both are running in the same node. ## Stopping an application -To stop a nova application you can call `nova_sup:remove_application/1` with the name of the application you want to stop. Use this with caution since calling this method all routes for all other applications will be removed and re-added in order to filter out the one removed. +```erlang +ok = nova_sup:remove_application(my_other_app). +``` + +The application's routes are removed from the listener serving it. If that +leaves the listener with no applications, the listener is stopped and its +routing table discarded, releasing the port. Other applications on the same +listener are unaffected. + +Removing an application that was never started returns `{error, not_found}`. + +## Inspecting what is running + +```erlang +nova_sup:get_started_applications(). +%% [#{app => my_app, host => {0,0,0,0}, port => 8080, listener => nova_listener}, +%% #{app => my_other_app, host => {0,0,0,0}, port => 8081, +%% listener => {nova_listener, my_other_app, 8081}}] +``` + +`nova_router:compiled_apps/0` lists the applications compiled into the default +listener's routing table, and `nova_router:compiled_apps/1` does the same for +any other. + +## Graceful shutdown + +Every listener Nova has started is suspended, drained and stopped on shutdown, +including ones added at runtime. See the +[graceful shutdown guide](graceful-shutdown.md). diff --git a/guides/routing.md b/guides/routing.md index 50a6b83..4bd872f 100644 --- a/guides/routing.md +++ b/guides/routing.md @@ -208,22 +208,95 @@ It's possible to configure a small set of endpoints with a specific plugin. This In the example above we have enabled the *pre-request*-plugin `nova_json_schemas` for all routes under the `/admin` prefix. This will cause all requests to be validated against the JSON schema defined in the `nova_json_schemas` plugin. You can also include *post-request*-plugins in the same way. +By default a route entry that declares `plugins` uses exactly those, and one +that does not uses the plugins configured globally. Set `plugin_strategy` on +the entry to combine them instead: + +| Value | Plugins used | +|---|---| +| `local_or_global` | The entry's own plugins if it declares any, otherwise the global ones. This is the default. | +| `local_first` | The entry's own plugins, then the global ones. | +| `global_first` | The global plugins, then the entry's own. | +| `local_only` | Only the entry's own plugins. An entry with none gets none. | +| `global_only` | Only the global plugins. | +| `{override, List}` | Exactly `List`. | + +When both sets are combined, a plugin appearing in both is kept once, at its +first position, so ordering within a phase is preserved. + +### Overriding security for an included application + +When you include another Nova application, you may want to put your own +security callback in front of its routes rather than the one it declares for +itself. Set `override_secure` in the options for that application: + +```erlang +{nova_apps, [{another_nova_app, #{prefix => "/admin", + override_secure => fun my_security:check/1}}]} +``` + +It takes the same values as `secure`: `false` for no override, a `fun/1`, or +the deprecated `{Module, Function}`. + + +## Route precedence + +More than one route can match a request. Nova resolves that the same way every +time, at each path segment in turn: + +1. A literal segment. +2. A binding, `:name`. When several bindings sit at the same depth they are + tried in name order. +3. A `[...]` catch-all. + +Matching backtracks, so a route is only skipped if nothing below it can match. +`/users/new` and `/users/:id` can therefore both be declared: `/users/new` +serves the literal path and `/users/:id` serves everything else. + +Once a path matches, the method is resolved. An exact method wins over a route +declared with `'_'`. If the path matches but the method does not, Nova answers +`405` with an `allow` header listing the methods that path does accept. + +If two routes declare the same path *and* the same method, the first one +registered wins and the second is logged and ignored. Set +`use_strict_routing` to `true` in the `nova` application environment to make +Nova refuse to start on a conflict instead, which also reports overlapping +literal and binding routes. ## Adding routes programatically -You can also add routes programatically by calling `nova_router:add_route/2`. This is useful if you want to add routes dynamically. The spec for it is: +You can also add routes programatically by calling `nova_router:add_routes/2`. This is useful if you want to add routes dynamically. The spec for it is: ```erlang -%% nova_router:add_route/2 specification --spec add_route(App :: atom(), Routes :: map() | [map()]) -> ok. +%% nova_router:add_routes/2 specification +-spec add_routes(App :: atom(), Routes :: map() | [map()]) -> ok. ``` First argument is the application you want to add the route to. The second argument is the route or a list of routes you want to add - it uses the same structure as in the regular routers. ```erlang -nova_router:add_route(my_app, #{prefix => "/admin", routes => [{"/", fun my_controller:main/1, #{methods => [get]}}]}). +nova_router:add_routes(my_app, #{prefix => "/admin", routes => [{"/", fun my_controller:main/1, #{methods => [get]}}]}). ``` This will add the routes defined in the second argument to the `my_app` application. -**Note**: If a route already exists it will be overwritten. +**Note**: If a route already exists it will be overwritten. This is the one +place where a later route wins; routes compiled at startup keep the first. + +If the application has a router module you can leave the routes out and let +Nova call it for you: + +```erlang +nova_router:add_routes(my_app). +``` + +To take an application's routes back out again: + +```erlang +nova_router:remove_application(my_app). +``` + +Both work on the routing table of the default listener. If you started the +application on its own listener with `nova_sup:add_application/2`, use +`nova_sup:remove_application/1` instead, which also stops the listener once +nothing is left on it. See the [multi-app guide](multi-app.md). diff --git a/rebar.config b/rebar.config index af2bd5e..13dc9e2 100644 --- a/rebar.config +++ b/rebar.config @@ -74,6 +74,7 @@ <<"guides/plugins.md">>, <<"guides/pubsub.md">>, <<"guides/multi-app.md">>, + <<"guides/deprecations.md">>, <<"guides/graceful-shutdown.md">>, <<"guides/building-releases.md">>, <<"guides/books-and-links.md">>, diff --git a/src/nova_router.erl b/src/nova_router.erl index 904d34e..d4e400a 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -79,13 +79,13 @@ compiled_apps() -> -spec compiled_apps(DispatchKey :: dispatch_key()) -> [{App :: atom(), Prefix :: list()}]. compiled_apps(DispatchKey) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), StorageBackend:get(apps_key(DispatchKey), []). %% TODO! We need to implement a way to get and remove plugins for a path plugins() -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), StorageBackend:get(?NOVA_PLUGINS, []). -spec compile(Apps :: [atom() | {atom(), map()}]) -> nova_routing_trie:trie(). @@ -102,7 +102,7 @@ compile(Apps) -> nova_routing_trie:trie(). compile(Apps, DispatchKey) -> UseStrict = application:get_env(nova, use_strict_routing, false), - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), StoredDispatch = StorageBackend:get(DispatchKey, nova_routing_trie:new(#{strict => UseStrict})), @@ -115,7 +115,7 @@ compile(Apps, DispatchKey) -> when Req::cowboy_req:req(), Env0::cowboy_middleware:env(). execute(Req = #{host := Host, path := Path, method := Method}, Env) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), Dispatch = StorageBackend:get(dispatch_key(Env)), case nova_routing_trie:find(Host, Path, Method, Dispatch) of {error, not_found} -> @@ -179,7 +179,7 @@ lookup_url(Host, Path) -> -spec lookup_url(Host :: binary() | atom(), Path :: nova_routing_trie:path(), Method :: nova_routing_trie:comparator()) -> lookup_result(). lookup_url(Host, Path, Method) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), Dispatch = StorageBackend:get(?NOVA_DISPATCH), lookup_url(Host, Path, Method, Dispatch). @@ -237,7 +237,7 @@ add_routes(App, Routes, _DispatchKey) -> throw({error, {invalid_routes, App, Routes}}). insert_route_maps(App, Routes, DispatchKey) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), Dispatch = StorageBackend:get(DispatchKey), %% Take out the prefix for the app and store it in the persistent store @@ -278,7 +278,7 @@ remove_application(Application) -> %%-------------------------------------------------------------------- -spec remove_application(Application :: atom(), DispatchKey :: dispatch_key()) -> ok. remove_application(Application, DispatchKey) when is_atom(Application) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), Dispatch = StorageBackend:get(DispatchKey), {ok, Dispatch0} = nova_routing_trie:foldl(Dispatch, @@ -309,7 +309,7 @@ route_app(_Route) -> undefine delete_dispatch(?NOVA_DISPATCH) -> ok; delete_dispatch(DispatchKey) -> - case application:get_env(nova, dispatch_backend, persistent_term) of + case storage_backend() of persistent_term -> persistent_term:erase(DispatchKey), persistent_term:erase(apps_key(DispatchKey)), @@ -365,7 +365,7 @@ compile([App|Tl], Dispatch, Options) -> {ok, Dispatch1, _Options2} = compile_paths(Routes, Dispatch, Options1), %% Take out the prefix for the app and store it in the persistent store - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), CompiledApps = StorageBackend:get(apps_key(maps:get(dispatch_key, Options, ?NOVA_DISPATCH)), []), @@ -555,7 +555,7 @@ render_status_page(StatusCode, Data, Req) -> Req :: cowboy_req:req(), Env :: map()) -> {ok, Req0 :: cowboy_req:req(), Env :: map()}. render_status_page(Host, StatusCode, Data, Req, Env) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), Dispatch = StorageBackend:get(dispatch_key(Env)), {Req0, Env0} = case nova_routing_trie:find(Host, StatusCode, '_', Dispatch) of @@ -584,6 +584,17 @@ render_status_page(Host, StatusCode, Data, Req, Env) -> insert_opts(T) -> maps:get(insert_opts, T, #{}). +%% The module the dispatch table is stored in. Configurable, so it has to be +%% narrowed to a module before it can be called. +-spec storage_backend() -> module(). +storage_backend() -> + case application:get_env(nova, dispatch_backend, persistent_term) of + Backend when is_atom(Backend) -> Backend; + Other -> + ?LOG_ERROR(#{reason => <<"dispatch_backend must be a module">>, value => Other}), + persistent_term + end. + %% The listener's dispatch key, defaulting to the one the bootstrap listener %% uses so an Env built before multi-listener support still resolves. dispatch_key(Env) -> @@ -618,7 +629,7 @@ insert(Host, Path, Combinator, Value, Tree, Options) -> add_plugin(Plugin) -> - StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), + StorageBackend = storage_backend(), StoredPlugins = StorageBackend:get(?NOVA_PLUGINS, []), Plugins1 = lists:umerge([[Plugin], StoredPlugins]), case Plugins1 of diff --git a/src/nova_routing_trie.erl b/src/nova_routing_trie.erl index 2286a0f..cc113c9 100644 --- a/src/nova_routing_trie.erl +++ b/src/nova_routing_trie.erl @@ -275,14 +275,31 @@ norm_options(Opts) -> -spec norm_host(host_in()) -> host_key(). norm_host('_') -> '_'; norm_host(Host) when is_binary(Host) -> Host; -norm_host(Host) when is_list(Host) -> unicode:characters_to_binary(Host); +norm_host(Host) when is_list(Host) -> to_binary(Host); norm_host(Host) when is_atom(Host) -> atom_to_binary(Host, utf8). -spec norm_comparator(comparator_in()) -> comparator(). -norm_comparator('_') -> '_'; -norm_comparator(C) when is_binary(C) -> string:uppercase(C); -norm_comparator(C) when is_atom(C) -> string:uppercase(atom_to_binary(C, utf8)); -norm_comparator(C) when is_list(C) -> string:uppercase(unicode:characters_to_binary(C)). +norm_comparator('_') -> '_'; +norm_comparator(C) when is_binary(C) -> upper(C); +norm_comparator(C) when is_atom(C) -> upper(atom_to_binary(C, utf8)); +norm_comparator(C) when is_list(C) -> upper(to_binary(C)). + +%% unicode:characters_to_binary/1 answers with an error tuple rather than +%% raising, and silently treating that as a segment would put an unroutable +%% route in the table. +-spec to_binary(unicode:chardata()) -> binary(). +to_binary(Data) -> + case unicode:characters_to_binary(Data) of + Binary when is_binary(Binary) -> Binary; + Error -> erlang:error({invalid_unicode, Error}) + end. + +-spec upper(binary()) -> binary(). +upper(Binary) -> + case string:uppercase(Binary) of + Upper when is_binary(Upper) -> Upper; + Other -> to_binary(Other) + end. %%==================================================================== %% Internal functions - path parsing @@ -293,7 +310,7 @@ norm_comparator(C) when is_list(C) -> string:uppercase(unicode:characters_t parse_path(StatusCode) when is_integer(StatusCode) -> [StatusCode]; parse_path(Path) when is_list(Path) -> - parse_path(unicode:characters_to_binary(Path)); + parse_path(to_binary(Path)); parse_path(Path) when is_binary(Path) -> [?ROOT | to_keys(split(Path), [])]; parse_path(Path) -> @@ -320,7 +337,7 @@ parse_lookup_path(Path) when is_list(Path) -> case lists:all(fun erlang:is_integer/1, Path) of true -> %% A flat string. - parse_lookup_path(unicode:characters_to_binary(Path)); + parse_lookup_path(to_binary(Path)); false -> %% Already-split segments. Segments = [seg_to_binary(S) || S <- Path], @@ -328,7 +345,7 @@ parse_lookup_path(Path) when is_list(Path) -> end. seg_to_binary(S) when is_binary(S) -> S; -seg_to_binary(S) when is_list(S) -> unicode:characters_to_binary(S); +seg_to_binary(S) when is_list(S) -> to_binary(S); seg_to_binary(S) when is_atom(S) -> atom_to_binary(S, utf8). split(Path) -> diff --git a/src/nova_sup.erl b/src/nova_sup.erl index 7c1d8a7..7c9de79 100644 --- a/src/nova_sup.erl +++ b/src/nova_sup.erl @@ -223,7 +223,7 @@ find_listener(Host, Port) -> setup_cowboy(Configuration) -> case start_cowboy(Configuration) of {ok, App, Host, Port} -> - Host0 = inet:ntoa(Host), + Host0 = format_host(Host), CowboyVersion = get_version(cowboy), NovaVersion = get_version(nova), UseStacktrace = application:get_env(nova, use_stacktrace, false), @@ -411,6 +411,11 @@ bind_tls(Ref, Host, Port, Configuration, CowboyOptions) -> {error, Reason} end. +%% The ip configuration key is an address tuple in practice, but ranch also +%% accepts a hostname, which inet:ntoa/1 would crash on. +format_host(Host) when is_tuple(Host) -> inet:ntoa(Host); +format_host(Host) -> Host. + get_version(Application) -> case lists:keyfind(Application, 1, application:loaded_applications()) of {_, _, Version} -> diff --git a/test/nova_full_app_SUITE.erl b/test/nova_full_app_SUITE.erl index fddde4f..bb9cbdc 100644 --- a/test/nova_full_app_SUITE.erl +++ b/test/nova_full_app_SUITE.erl @@ -11,7 +11,35 @@ %%% and a case here. -module(nova_full_app_SUITE). --compile([export_all, nowarn_export_all]). +-export([all/0, groups/0, init_per_suite/1, end_per_suite/1]). + +-export([ + root_route/1, + json_route/1, + single_binding/1, + multiple_bindings/1, + literal_beats_binding/1, + all_declared_methods/1, + any_method_route/1, + method_not_allowed/1, + redirect/1, + custom_status_code/1, + extra_state_reaches_controller/1, + host_scoped_route/1, + static_file_from_priv/1, + static_file_nested/1, + static_directory_index/1, + static_file_missing/1, + secure_route_rejects_anonymous/1, + secure_route_accepts_token/1, + auth_data_reaches_controller/1, + pre_and_post_request_plugins_run/1, + custom_not_found/1, + controller_crash_is_a_500/1, + websocket_echo/1, + sub_app_mounted_under_prefix/1, + add_and_remove_application/1 + ]). -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). diff --git a/test/nova_multi_listener_SUITE.erl b/test/nova_multi_listener_SUITE.erl index b168207..8d2ae7e 100644 --- a/test/nova_multi_listener_SUITE.erl +++ b/test/nova_multi_listener_SUITE.erl @@ -6,7 +6,17 @@ %%% application to an already-bound port, and tearing both back down. -module(nova_multi_listener_SUITE). --compile([export_all, nowarn_export_all]). +-export([all/0, init_per_suite/1, end_per_suite/1]). + +-export([ + bootstrap_listener_is_registered/1, + second_application_gets_its_own_port/1, + listeners_do_not_serve_each_others_routes/1, + second_application_on_a_bound_port_shares_the_listener/1, + adding_a_started_application_again_is_an_error/1, + removing_an_application_stops_only_its_listener/1, + removing_an_unknown_application_is_an_error/1 + ]). -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl").