Packages
nova
0.4.1
0.15.1
0.14.3
0.14.1
0.13.7
0.13.1
0.13.0
0.12.1
0.12.0
0.11.0
0.10.4
0.10.3
0.10.2
0.10.1
0.10.0
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.11
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.4.4
0.4.3
0.4.2
0.4.1
0.2.2
0.1.0
0.0.1
0.0.0
Nova is a web application framework
Current section
Files
Jump to
Current section
Files
src/nova_router.erl
%%%-------------------------------------------------------------------
%%% @author Niclas Axelsson <niclas@burbas.se>
%%% @doc
%%% <i>Nova router</i> is in charge of all routing in Nova. When a nova application
%%% is started its routing-file is read and processed by the router. You should
%%% not use this module directly if you're not certain of what you are doing.
%%%
%%% A basic routing file could look something like this;
%%% <code title="priv/my_demo_app.routes.erl">
%%% #{prefix => "",
%%% security => false,
%%% routes => [
%%% {"/", {hello_world_controller, index}},
%%% {"/blog", {my_blog_controller, index}},
%%% {404, {error_controller, not_found}}
%%% ],
%%% statics => [
%%% {"/assets/[...]", "assets"}
%%% ]
%%% }.
%%% </code>
%%% Lets go through it in sections.
%%%
%%% <icode>prefix</icode> - This tells us if the routes should have a common prefix (Eg "/v1" or similar)
%%%
%%% <icode>security</icode> - If this is a tuple of type <icode>{Module, Function}</icode> Nova will try and call
%%% that function each time a page from the <i>routes</i>-section is visited. That function could return either
%%% true or false, telling nova if the user should be allowed to visit the page or not.
%%%
%%% <icode>routes</icode> - Describes the routes inside this block. (An application can have several of these maps
%%% defined)
%%%
%%% <icode>statics</icode> - Routes for all the static assets
%%%
%%% <h3>Routing more in detail</h3>
%%% There's three different ways of describing a route, depending on what kind of protocol (http, websocket) or what
%%% options you have.
%%%
%%% <icode>{Route, {Module, Function}}</icode> - This is a regular route used for HTTP.
%%% @end
%%% @todo Extend the introduction to how routing works.
%%% @end
%%% Created : 17 Nov 2019 by Niclas Axelsson <niclas@burbas.se>
%%%-------------------------------------------------------------------
-module(nova_router).
-behaviour(gen_server).
%% API
-export([
start_link/0,
process_routefile/1,
status_page/2,
add_route/2,
get_all_routes/0,
get_main_app/0,
apply_routes/0,
get_app/1
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3,
format_status/2
]).
-include_lib("nova/include/nova.hrl").
-define(SERVER, ?MODULE).
-define(STATIC_ROUTE_TABLE, static_route_table).
-type route_info() :: #{application := atom(),
prefix := atom(),
host := atom() | string(),
security := false | {atom(), atom()},
_ => _}.
-export_type([route_info/0]).
-type route() :: {Route :: string(), {Module :: atom(), Function :: atom()}} |
{Route :: string(), {Module :: atom(), Function :: atom()}, Options :: map()} |
{Route :: string(), Module :: atom(), Function :: atom()} |
{Route :: string(), CallbackInfo :: atom(), Options :: map()} |
{StatusCode :: integer(), {Module :: atom(), Function :: atom()}} |
{Route :: string(), Filename :: string()}.
-export_type([route/0]).
-type app_info() :: #{name := atom(),
prefix := string(),
host := atom() | string(),
security := false | {atom(), atom()},
routes := [route()]}.
-export_type([app_info/0]).
-record(state, {
main_app :: atom(),
apps :: app_info() | #{},
route_table :: [{binary(), list()}] | [],
static_route_table :: #{StatusCode :: integer() => {Mod :: atom(), Func :: atom()}}
}).
%%%===================================================================
%%% API
%%%===================================================================
%%--------------------------------------------------------------------
%% @doc
%% Starts the server
%% @hidden
%% @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, [], []).
%%--------------------------------------------------------------------
%% @doc
%% Checks if there is a route for a special "status page" in the route
%% table. This is done outside of cowboy since they does not support having
%% custom pages for status pages (Eg 404)
%% @end
%%--------------------------------------------------------------------
-spec status_page(Status :: integer(), Req :: cowboy_req:req()) ->
{ok, StatusCode :: integer(), Headers :: cowboy:http_headers(), Body :: binary(),
State0 :: nova_http_handler:nova_http_state()} | {error, not_found}.
status_page(Status, Req) when is_integer(Status) ->
gen_server:call(?SERVER, {fetch_status_page, Status, Req}).
%%--------------------------------------------------------------------
%% @doc
%% Add a route to nova.
%% @end
%%--------------------------------------------------------------------
-spec add_route(RouteInfo :: route_info(), Route :: route()) -> ok.
add_route(RouteInfo, Route = {_, FileOrDir}) when is_list(FileOrDir) ->
gen_server:cast(?SERVER, {add_static, RouteInfo, Route});
add_route(RouteInfo, Route) ->
gen_server:cast(?SERVER, {add_route, RouteInfo, Route}).
%%--------------------------------------------------------------------
%% @doc
%% Returns all the routes for this node. The RouteTable contains all
%% routes injected into cowboy while the StaticRouteTable contains
%% route information about status pages (eg 404).
%% @end
%%--------------------------------------------------------------------
-spec get_all_routes() -> {ok, {RouteTable :: list(), StaticRouteTable :: map()}}.
get_all_routes() ->
gen_server:call(?SERVER, get_all_routes).
%%--------------------------------------------------------------------
%% @doc
%% Returns all the information about a certain app and all of it's
%% routes.
%% @end
%%--------------------------------------------------------------------
-spec get_app(App :: atom()) -> {ok, app_info()} | {error, Reason :: atom()}.
get_app(App) ->
gen_server:call(?SERVER, {get_app, App}).
%%--------------------------------------------------------------------
%% @doc
%% Returns the name of the application that intiated the start.
%% @end
%%--------------------------------------------------------------------
-spec get_main_app() -> atom().
get_main_app() ->
gen_server:call(?SERVER, get_main_app).
%%--------------------------------------------------------------------
%% @doc
%% Process the routefile for the specified application and injects the
%% resulting route-table into cowboy.
%% @end
%% @todo
%% We need this to work in a recursive manner.
%% @end
%%--------------------------------------------------------------------
-spec process_routefile(#{name := atom(), routes_file => list()}) -> ok.
process_routefile(#{name := Application, routes_file := RouteFile}) ->
case code:lib_dir(Application) of
{error, bad_name} ->
?WARNING("Could not find the application ~p. Check your config and rerun the application", [Application]),
ok;
Filepath ->
?DEBUG("Processing routefile: ~p", [Filepath]),
RouteFilePath = filename:join([Filepath, RouteFile]),
{ok, AppRoutes} = file:consult(RouteFilePath),
lists:foreach(fun(AppMap) ->
%% Extract information
Prefix = maps:get(prefix, AppMap, ""),
Host = maps:get(host, AppMap, '_'),
Routes = maps:get(routes, AppMap, []),
Statics = maps:get(statics, AppMap, []),
Secure = maps:get(security, AppMap, false),
%% Built intermediate object
RouteInfo = #{application => Application,
prefix => Prefix,
host => Host,
security => Secure},
%% Check for handlers
Handlers = maps:get(handlers, AppMap, []),
[ nova_handlers:register_handler(Handle, Callback) ||
{Handle, Callback} <- Handlers ],
%% Add routes
[ add_route(RouteInfo, Route) || Route <- Routes ++ Statics ]
end, AppRoutes)
end;
process_routefile(AppInfo = #{name := Application}) ->
Routename = lists:concat(["priv/", Application, ".routes.erl"]),
process_routefile(AppInfo#{routes_file => Routename}).
%%--------------------------------------------------------------------
%% @doc
%% Takes all the routes in nova_router and applies them in cowboy_router.
%% The changes should be instant.
%% @end
%%--------------------------------------------------------------------
-spec apply_routes() -> ok.
apply_routes() ->
gen_server:cast(?SERVER, apply_routes).
%%%===================================================================
%%% 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, MainApplication} = application:get_application(),
Apps = application:get_env(MainApplication, nova_applications, []),
?DEBUG("Bootstrapping router for application ~p, included_apps: ~p", [MainApplication, Apps]),
[ process_routefile(NovaApp) || NovaApp <- Apps ],
apply_routes(),
{ok, #state{
main_app = MainApplication,
route_table = [],
apps = #{},
static_route_table = #{}
}}.
%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling call messages
%% @end
%%--------------------------------------------------------------------
-spec handle_call(Request :: term() | {fetch_status_page, Status :: integer(), Req :: cowboy_req:req()},
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(get_main_app, _From, State = #state{main_app = MainApp}) ->
{reply, MainApp, State};
handle_call({get_app, App}, _From, State = #state{apps = AppsInfo}) ->
Reply =
case maps:get(App, AppsInfo, undefined) of
undefined ->
{error, not_found};
AppInfo ->
{ok, AppInfo}
end,
{reply, Reply, State};
handle_call({fetch_status_page, Status, Req}, _From,
State = #state{static_route_table = StaticRouteTable}) ->
case maps:get(Status, StaticRouteTable, undefined) of
{Mod, Func} ->
Reply = nova_http_handler:handle(Mod, Func, Req, #{mod => dummy,
func => dummy,
methods => '_'}),
{reply, Reply, State};
_ ->
{reply, {error, not_found}, State}
end;
handle_call(get_all_routes, _From, State = #state{route_table = RoutesTable,
static_route_table = StaticRouteTable}) ->
{reply, {ok, {RoutesTable, StaticRouteTable}}, State};
handle_call(Request, _From, State) ->
?WARNING("Unknown request: ~p when state: ~p", [Request, State]),
{reply, error, 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({add_static, #{application := Application, prefix := Prefix,
host := Host, security := _Security}, RouteDetails = {Route, DirOrFile}},
State = #state{route_table = RouteTable, apps = AppsInfo}) ->
case code:lib_dir(Application, priv) of
{error, _} ->
?ERROR("Could not apply route ~p. Could not find priv dir of application ~p", [RouteDetails, Application]),
{noreply, State};
PrivDir ->
CowboyRoute =
case {filelib:is_dir(filename:join([PrivDir, DirOrFile])),
filelib:is_file(filename:join([PrivDir, DirOrFile]))} of
{true, _} ->
{Prefix ++ Route, cowboy_static, {priv_dir, Application, DirOrFile}};
{false, true} ->
{Prefix ++ Route, cowboy_static, {priv_file, Application, DirOrFile}};
_ ->
?WARNING("Could not find the static file ~p which is reffered from the route ~p. " ++
"Ignoring route", [DirOrFile, RouteDetails]),
false
end,
case CowboyRoute of
false ->
{noreply, State};
_ ->
AppsInfo0 = add_route_to_app(Application, Prefix, Host, false, RouteDetails, AppsInfo),
NewRouteTable = prop_upsert(Host, CowboyRoute, RouteTable),
{noreply, State#state{route_table = NewRouteTable, apps = AppsInfo0}}
end
end;
handle_cast({add_route, #{application := Application, prefix := Prefix,
host := Host}, RouteDetails = {StatusCode, {Module, Function}}},
State = #state{static_route_table = StaticRouteTable, apps = AppsInfo}) when is_integer(StatusCode) ->
%% Do something with the status code
StaticRouteTable0 = maps:put(StatusCode, {Module, Function}, StaticRouteTable),
AppsInfo0 = add_route_to_app(Application, Prefix, Host, false, RouteDetails, AppsInfo),
?DEBUG("Applying status-route for code ~p, MF: ~p", [StatusCode, {Module, Function}]),
{noreply, State#state{static_route_table = StaticRouteTable0, apps = AppsInfo0}};
handle_cast({add_route, #{application := Application, prefix := Prefix,
host := Host, security := Security}, RouteDetails},
State = #state{route_table = RouteTable, apps = AppsInfo}) ->
InitialState = #{app => Application,
secure => Security},
CowboyRoute =
case RouteDetails of
{Route, {Module, Function}} ->
{Prefix ++ Route,
nova_http_handler,
InitialState#{mod => Module,
func => Function,
methods => '_',
nova_handler => nova_http_handler}};
{Route, CallbackInfo, Options = #{protocol := ws}} ->
{Prefix ++ Route,
nova_ws_handler,
InitialState#{mod => CallbackInfo,
subprotocols => maps:get(subprotocols, Options, []),
nova_handler => nova_ws_handler}};
{Route, {Module, Function}, Options} ->
{Prefix ++ Route,
nova_http_handler,
InitialState#{mod => Module,
func => Function,
methods => get_methods(Options),
nova_handler => nova_http_handler}};
{_Route, _Module, _Function} ->
%% This is to keep legacy-format. Deprecated
?DEPRECATED("Route of format {Route, Module, Function} is deprecated!"),
erlang:throw({deprecated_route_format, RouteDetails});
Other ->
?WARNING("Could not parse route ~p", [Other]),
erlang:throw({route_error, Other})
end,
?DEBUG("Adding route: ~p", [RouteDetails]),
AppsInfo0 = add_route_to_app(Application, Prefix, Host, Security, RouteDetails, AppsInfo),
NewRouteTable = prop_upsert(Host, CowboyRoute, RouteTable),
{noreply, State#state{route_table = NewRouteTable, apps = AppsInfo0}};
handle_cast(apply_routes, State = #state{route_table = RouteTable}) ->
Dispatch = cowboy_router:compile(RouteTable),
cowboy:set_env(nova_listener, dispatch, Dispatch),
{noreply, State};
handle_cast(Request, State) ->
?WARNING("Got unknown cast in router: ~p", [Request]),
{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
%%%===================================================================
get_methods(#{methods := M}) when is_list(M) ->
Res = lists:map(fun(get) -> <<"GET">>;
(post) -> <<"POST">>;
(put) -> <<"PUT">>;
(delete) -> <<"DELETE">>;
(_) -> throw(unknown_method)
end, M),
case length(Res) of
4 -> '_';
_ -> Res
end;
get_methods(#{methods := M}) ->
get_methods(#{methods => [M]});
get_methods(_) ->
'_'.
prop_upsert(Key, NewEntry, Proplist) ->
case proplists:lookup(Key, Proplist) of
none ->
[{Key, [NewEntry]}|Proplist];
{Key, OldList} ->
[{Key, [NewEntry|OldList]}|proplists:delete(Key, Proplist)]
end.
add_route_to_app(App, Prefix, Host, Security, Route, AppMap) when is_map(AppMap) ->
case maps:get(App, AppMap, undefined) of
undefined ->
#{App => #{name => App,
prefix => Prefix,
host => Host,
security => Security,
routes => [Route]}};
Result ->
AppMap#{App => Result#{routes => [Route|maps:get(routes, Result)]}}
end.