Packages
nova
0.13.7
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
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(nova_router).
-behaviour(cowboy_middleware).
%% Cowboy middleware-callbacks
-export([
execute/2
]).
%% API
-export([
compile/1,
lookup_url/1,
lookup_url/2,
lookup_url/3,
render_status_page/2,
render_status_page/3,
render_status_page/5,
%% Expose the router-callback
routes/1,
%% Modulates the routes-table
add_routes/2,
%% Fetch information about the routing table
plugins/0,
compiled_apps/0
]).
-include_lib("routing_tree/include/routing_tree.hrl").
-include_lib("kernel/include/logger.hrl").
-include("../include/nova_router.hrl").
-include("../include/nova.hrl").
-type bindings() :: #{binary() := binary()}.
-export_type([bindings/0]).
%% This module is also exposing callbacks for routers
-callback routes(Env :: atom()) -> Routes :: [map()].
-callback controllers(Env :: atom()) -> Controllers :: [module() | {module(), map()}].
-optional_callbacks([routes/1, controllers/1]).
-define(NOVA_APPS, nova_apps).
-define(NOVA_PLUGINS, nova_plugins).
-spec compiled_apps() -> [{App :: atom(), Prefix :: list()}].
compiled_apps() ->
StorageBackend = application:get_env(nova, dispatch_backend, persistent_term),
StorageBackend:get(?NOVA_APPS, []).
plugins() ->
StorageBackend = application:get_env(nova, dispatch_backend, persistent_term),
StorageBackend:get(?NOVA_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),
StorageBackend:put(nova_dispatch, Dispatch),
Dispatch.
-spec execute(Req, Env :: cowboy_middleware:env()) -> {ok, Req, Env0} | {stop, Req}
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),
Dispatch = StorageBackend:get(nova_dispatch),
case routing_tree:lookup(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);
{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], ", ")),
%% 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}} ->
{ok,
Req#{plugins => Plugins,
extra_state => ExtraState,
bindings => Bindings},
Env#{app => App,
callback => Callback,
secure => Secure,
controller_data => #{}
}
};
{ok, Bindings, #nova_handler_value{app = App, callback = Callback,
secure = Secure, plugins = Plugins, extra_state = ExtraState}, Pathinfo} ->
{ok,
Req#{plugins => Plugins,
extra_state => ExtraState#{pathinfo => Pathinfo},
bindings => Bindings},
Env#{app => App,
callback => Callback,
secure => Secure,
controller_data => #{}
}
};
{ok, Bindings, #cowboy_handler_value{app = App, handler = Handler, arguments = Args,
plugins = Plugins, secure = Secure}} ->
{ok,
Req#{plugins => Plugins,
bindings => Bindings},
Env#{app => App,
cowboy_handler => Handler,
arguments => Args,
secure => Secure
}
};
Error ->
?LOG_ERROR(#{reason => <<"Unexpected return from routing_tree:lookup/4">>,
return_object => Error}),
render_status_page(Host, 404, #{error => Error}, Req, Env)
end.
lookup_url(Path) ->
lookup_url('_', Path).
lookup_url(Host, Path) ->
lookup_url(Host, Path, '_').
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).
lookup_url(Host, Path, Method, Dispatch) ->
routing_tree:lookup(Host, Path, Method, Dispatch).
%%--------------------------------------------------------------------
%% @doc
%% Add routes to the dispatch-table for the given app. The routes
%% can be either a list of maps or a map. It use the same structure as
%% the routes-callback in the router-module.
%% @end
%%--------------------------------------------------------------------
-spec add_routes(App :: atom(), Routes :: [map()] | map()) -> ok.
add_routes(_App, []) -> ok;
add_routes(App, [Routes|Tl]) when is_list(Routes) ->
Options = #{},
StorageBackend = application:get_env(nova, dispatch_backend, persistent_term),
Dispatch = StorageBackend:get(nova_dispatch),
%% Take out the prefix for the app and store it in the persistent store
CompiledApps = StorageBackend:get(?NOVA_APPS, []),
CompiledApps0 =
case lists:keyfind(App, 1, CompiledApps) of
false ->
[{App, maps:get(prefix, Options, "/")}|CompiledApps];
_StoredApp ->
CompiledApps
end,
Options1 = Options#{app => App},
{ok, Dispatch1, _Options2} = compile_paths(Routes, Dispatch, Options1),
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}}).
%%%%%%%%%%%%%%%%%%%%%%%%
%% INTERNAL FUNCTIONS %%
%%%%%%%%%%%%%%%%%%%%%%%%
get_routes(Router, Env) ->
%% Call the router
Controllers = apply_callback(Router, controllers, [Env]),
apply_callback(Router, routes, [Env])
++ lists:append([nova_controller:routes(C, Env) || C <- Controllers ]).
%% yields an empty list if callback does not exist
apply_callback(Module, Function, Args) ->
Arity = length(Args),
%% try to ensure callback module is loaded first
try Module:module_info(module)
catch _:_ -> ok
end,
case erlang:function_exported(Module, Function, Arity) of
true ->
apply(Module, Function, Args);
false ->
[]
end.
-spec compile(Apps :: [atom() | {atom(), map()}], Dispatch :: host_tree(), Options :: map()) -> host_tree().
compile([], Dispatch, _Options) -> Dispatch;
compile([{App, Options}|Tl], Dispatch, GlobalOptions) ->
compile([App|Tl], Dispatch, maps:merge(Options, 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]))
end,
Env = nova:get_environment(),
Routes = get_routes(Router, Env),
CompileParameters = Router:module_info(compile),
RouterFile = proplists:get_value(source, CompileParameters),
Options1 = Options#{app => App, router_file => RouterFile},
{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],
StorageBackend:put(?NOVA_APPS, CompiledApps0),
compile(Tl, Dispatch1, Options2).
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),
Secure =
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,
Value = #nova_handler_value{secure = Secure, app = App, plugins = normalize_plugins(Plugins),
extra_state = maps:get(extra_state, 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],
nova:set_env(apps, NovaEnv0),
{ok, Dispatch1} = parse_url(Host, maps:get(routes, RouteInfo, []), #{prefix => Prefix,
router_file => maps:get(router_file, Options)},
Value, Dispatch),
Dispatch2 = compile(SubApps, Dispatch1, Options#{value => Value, prefix => Prefix}),
compile_paths(Tl, Dispatch2, Options).
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, ['_'])),
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) ->
parse_url(Host, [{RemotePath, LocalPath, #{}}|Tl], T, Value, Tree);
parse_url(Host, [{RemotePath, LocalPath, Options}|Tl], T = #{prefix := Prefix},
Value = #nova_handler_value{app = App, secure = Secure}, Tree) when is_list(RemotePath), is_list(LocalPath) ->
%% Static assets - check that the path exists
PrivPath = filename:join(code:priv_dir(App), LocalPath),
Payload =
case {filelib:is_dir(LocalPath), filelib:is_dir(PrivPath)} of
{false, false} ->
%% No directory - check if it's a file
case {filelib:is_file(LocalPath), filelib:is_file(PrivPath)} of
{false, false} ->
%% No dir nor file
?LOG_WARNING(#{reason => <<"Could not find local path for the given resource">>,
local_path => LocalPath,
remote_path => RemotePath,
router_file => maps:get(router_file, Options, undefined)}),
not_found;
{true, false} ->
{file, LocalPath};
{_, true} ->
{priv_file, App, LocalPath}
end;
{true, false} ->
{dir, LocalPath};
{_, true} ->
{priv_dir, App, LocalPath}
end,
TargetFun = case Payload of
{file, _} -> get_file;
{priv_file, _, _} -> get_file;
{dir, _} -> get_dir;
{priv_dir, _, _} -> get_dir
end,
Value0 = #nova_handler_value{
app = App,
callback = fun nova_file_controller:TargetFun/1,
extra_state = #{static => Payload, options => Options},
plugins = Value#nova_handler_value.plugins,
secure = Secure
},
Tree0 = insert(Host, string:concat(Prefix, RemotePath), '_', Value0, Tree),
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),
?LOG_DEPRECATED(<<"v0.9.24">>, <<"The {Mod,Fun} format have been deprecated. Use the new format for routes.">>, RouterFile),
parse_url(Host, [{Path, fun Mod:Func/1, Options}|Tl], T, Value, Tree);
parse_url(Host, [{Path, Callback}|Tl], T, Value, Tree) when is_function(Callback) ->
%% Recurse with same args but with added options
parse_url(Host, [{Path, Callback, #{}}|Tl], T, Value, Tree);
parse_url(Host, [{Path, Callback, Options}|Tl], T = #{prefix := Prefix}, Value = #nova_handler_value{app = App}, Tree)
when is_function(Callback) ->
case maps:get(protocol, Options, http) of
http ->
%% Transform the path to a string format
RealPath = concat_strings(Prefix, Path),
Methods = maps:get(methods, Options, ['_']),
ExtraState = maps:get(extra_state, Options, undefined),
Value0 = Value#nova_handler_value{extra_state = ExtraState},
CompiledPaths =
lists:foldl(
fun(Method, Tree0) ->
BinMethod = method_to_binary(Method),
Value1 = Value0#nova_handler_value{
callback = Callback
},
?LOG_DEBUG(#{action => <<"Adding route">>, route => RealPath, app => App, method => Method,
router_file => maps:get(router_file, Options, undefined)}),
insert(Host, RealPath, BinMethod, Value1, Tree0)
end, Tree, Methods),
parse_url(Host, Tl, T, Value, CompiledPaths);
OtherProtocol ->
?LOG_ERROR(#{reason => <<"Unknown protocol">>, protocol => OtherProtocol,
router_file => maps:get(router_file, Options, undefined)}),
parse_url(Host, Tl, T, Value, Tree)
end;
parse_url(Host,
[{Path, Mod, #{protocol := ws}} | Tl],
T = #{prefix := Prefix}, #nova_handler_value{app = App, secure = Secure} = Value,
Tree) when is_atom(Mod) ->
Value0 = #cowboy_handler_value{
app = App,
handler = nova_ws_handler,
arguments = #{module => Mod},
plugins = Value#nova_handler_value.plugins,
secure = Secure},
?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),
parse_url(Host, Tl, T, Value, CompiledPaths).
-spec render_status_page(StatusCode :: integer(), Req :: cowboy_req:req()) ->
{ok, Req0 :: cowboy_req:req(), Env :: map()}.
render_status_page(StatusCode, Req) ->
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}).
-spec render_status_page(Host :: binary() | atom(),
StatusCode :: integer(),
Data :: map(),
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),
Dispatch = StorageBackend:get(nova_dispatch),
{Req0, Env0} =
case routing_tree:lookup(Host, StatusCode, '_', Dispatch) of
{error, _} ->
%% Render nova page if exists - We need to determine where to find this path?
{Req, Env#{app => nova,
callback => fun nova_error_controller:status_code/1,
secure => false,
controller_data => #{status => StatusCode, data => Data}}};
{ok, Bindings, #nova_handler_value{app = App,
callback = Callback,
secure = Secure,
extra_state = ExtraState}} ->
{
Req#{extra_state => ExtraState, bindings => Bindings, resp_status_code => StatusCode},
Env#{app => App,
callback => Callback,
secure => Secure,
controller_data => #{status => StatusCode, data => Data},
bindings => Bindings}
}
end,
{ok, Req0#{resp_status_code => StatusCode}, Env0}.
insert(Host, Path, Combinator, Value, Tree) ->
try routing_tree:insert(Host, Path, Combinator, Value, Tree) of
Tree0 -> Tree0
catch
throw:Exception ->
?LOG_ERROR(#{reason => <<"Error when inserting route">>, route => Path, combinator => Combinator}),
throw(Exception);
Type:Exception ->
?LOG_ERROR(#{reason => <<"Unexpected exit">>, type => Type, exception => Exception}),
throw(Exception)
end.
add_plugin(Plugin) ->
StorageBackend = application:get_env(nova, dispatch_backend, persistent_term),
StoredPlugins = StorageBackend:get(?NOVA_PLUGINS, []),
Plugins1 = lists:umerge([[Plugin], StoredPlugins]),
case Plugins1 of
StoredPlugins ->
ok;
_ ->
StorageBackend:put(?NOVA_PLUGINS, Plugins1)
end.
normalize_plugins(Plugins) ->
NormalizedPlugins = normalize_plugins(Plugins, []),
[{Type, lists:reverse(TypePlugins)} || {Type, TypePlugins} <- NormalizedPlugins].
normalize_plugins([], Ack) -> Ack;
normalize_plugins([{Type, PluginName, Options}|Tl], Ack) ->
ExistingPlugins = proplists:get_value(Type, Ack, []),
add_plugin(PluginName),
normalize_plugins(Tl, [{Type, [{fun PluginName:Type/4, Options}|ExistingPlugins]}|proplists:delete(Type, Ack)]).
method_to_binary(get) -> <<"GET">>;
method_to_binary(post) -> <<"POST">>;
method_to_binary(put) -> <<"PUT">>;
method_to_binary(delete) -> <<"DELETE">>;
method_to_binary(options) -> <<"OPTIONS">>;
method_to_binary(head) -> <<"HEAD">>;
method_to_binary(connect) -> <<"CONNECT">>;
method_to_binary(trace) -> <<"TRACE">>;
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(Path1, Path2) when is_binary(Path2) ->
concat_strings(Path1, binary_to_list(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).
-spec routes(Env :: atom()) -> [map()].
routes(_) ->
[#{
routes => [
{404, fun nova_error_controller:not_found/1, #{}},
{500, fun nova_error_controller:server_error/1, #{}}
]
}].
-ifdef(TEST).
-compile(export_all).
-endif.