diff --git a/guides/routing.md b/guides/routing.md index 50a6b83..0c74a13 100644 --- a/guides/routing.md +++ b/guides/routing.md @@ -86,6 +86,27 @@ As you saw in the initial example in the [Basic components](#basic-components) s > > One needs to define `protocol => ws` in the options-map in order to enable websocket communications. +### Native cowboy handlers ### + +``` +{Route :: list(), Handler :: atom(), Options :: map()} +``` + +By defining `protocol => cowboy` in the options-map, the handler module is invoked +directly with `Handler:init/2` the same way cowboy itself would do it. This means that +plain cowboy handlers, as well as handlers using cowboy's sub-protocols such as +`cowboy_rest` and `cowboy_loop`, can be routed through nova without any wrapping - +while still passing through nova's plugin- and security-handling. + +The arguments given to `Handler:init/2` can be set with the `arguments`-option +(defaults to `#{}`), and the route can be restricted with the `methods`-option +just like regular routes. + +Example: +```erlang +{"/my-rest-api", my_rest_handler, #{protocol => cowboy}}, +{"/long-poll", my_loop_handler, #{protocol => cowboy, arguments => #{timeout => 30000}}} +``` ### Static files diff --git a/src/nova_router.erl b/src/nova_router.erl index e95d07a..9889149 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -369,6 +369,26 @@ parse_url(Host, [{Path, Callback, Options}|Tl], T = #{prefix := Prefix}, Value = router_file => maps:get(router_file, Options, undefined)}), parse_url(Host, Tl, T, Value, Tree) end; +parse_url(Host, [{Path, Handler, Options = #{protocol := cowboy}}|Tl], T = #{prefix := Prefix}, + Value = #nova_handler_value{app = App, secure = Secure}, Tree) when is_atom(Handler) -> + %% Plain cowboy handler (eg cowboy_rest, cowboy_loop or a basic + %% cowboy_handler). The handler module is invoked with Handler:init/2 + %% and any sub-protocol upgrade it returns is honored by nova_handler. + Value0 = #cowboy_handler_value{ + app = App, + handler = Handler, + arguments = maps:get(arguments, Options, #{}), + plugins = Value#nova_handler_value.plugins, + secure = Secure}, + ?LOG_DEBUG(#{action => <<"Adding route">>, protocol => <<"cowboy">>, route => Path, app => App, + router_file => maps:get(router_file, T, undefined)}), + RealPath = concat_strings(Prefix, Path), + CompiledPaths = + lists:foldl( + fun(Method, Tree0) -> + insert(Host, RealPath, method_to_binary(Method), Value0, Tree0) + end, Tree, maps:get(methods, Options, ['_'])), + parse_url(Host, Tl, T, Value, CompiledPaths); parse_url(Host, [{Path, Mod, #{protocol := ws}} | Tl], T = #{prefix := Prefix}, #nova_handler_value{app = App, secure = Secure} = Value, diff --git a/test/base_app_router.erl b/test/base_app_router.erl new file mode 100644 index 0000000..f64b8a7 --- /dev/null +++ b/test/base_app_router.erl @@ -0,0 +1,19 @@ +%%% Router fixture for nova_cowboy_handler_SUITE. +-module(base_app_router). + +-export([routes/1, hello/1]). + +routes(_Env) -> + [#{prefix => "", + security => false, + routes => [ + {"/base", fun base_app_router:hello/1, #{methods => [get]}}, + {"/plain", test_plain_handler, #{protocol => cowboy, methods => [get]}}, + {"/loop", test_loop_handler, #{protocol => cowboy}}, + {"/loop-args", test_loop_handler, #{protocol => cowboy, + arguments => #{body => <<"loop-args-ok">>}}}, + {"/stream", test_stream_handler, #{protocol => cowboy}} + ]}]. + +hello(_Req) -> + {json, #{app => <<"base">>}}. diff --git a/test/nova_cowboy_handler_SUITE.erl b/test/nova_cowboy_handler_SUITE.erl new file mode 100644 index 0000000..81ba4e4 --- /dev/null +++ b/test/nova_cowboy_handler_SUITE.erl @@ -0,0 +1,80 @@ +%%% Tests that plain cowboy handlers, as well as handlers using cowboy's +%%% sub-protocols (eg cowboy_loop), can be routed through nova with +%%% protocol => cowboy. +-module(nova_cowboy_handler_SUITE). + +-include_lib("common_test/include/ct.hrl"). + +-compile(export_all). +-compile(nowarn_export_all). + +-define(PORT, 10080). + +all() -> + [regular_route_still_works, + plain_cowboy_handler, + loop_cowboy_handler, + handler_arguments, + streaming_handler, + method_restriction]. + +init_per_suite(Config) -> + application:load(nova), + application:set_env(nova, bootstrap_application, base_app), + application:set_env(nova, cowboy_configuration, #{port => ?PORT}), + {ok, _} = application:ensure_all_started(nova), + {ok, _} = application:ensure_all_started(inets), + Config. + +end_per_suite(_Config) -> + application:stop(nova), + ok. + +%% Sanity check - ordinary nova routes are unaffected. +regular_route_still_works(_Config) -> + {200, Body} = request(get, "/base"), + {ok, #{<<"app">> := <<"base">>}} = thoas:decode(list_to_binary(Body)), + ok. + +%% A basic cowboy handler replying directly from init/2. +plain_cowboy_handler(_Config) -> + {200, "plain-ok"} = request(get, "/plain"), + ok. + +%% A cowboy_loop handler - the sub-protocol upgrade returned from +%% init/2 is honored. +loop_cowboy_handler(_Config) -> + {200, "loop-ok"} = request(get, "/loop"), + ok. + +%% The arguments-option is passed as state to Handler:init/2. +handler_arguments(_Config) -> + {200, "loop-args-ok"} = request(get, "/loop-args"), + ok. + +%% A handler streaming its response in several chunks with +%% cowboy_req:stream_reply/3 and cowboy_req:stream_body/3. +streaming_handler(_Config) -> + {200, "chunk1chunk2chunk3done"} = request(get, "/stream"), + ok. + +%% The methods-option restricts cowboy-routes just like regular routes. +method_restriction(_Config) -> + {405, _} = request(post, "/plain"), + ok. + +%%%%%%%%%%%%%%%%%%%%%%%% +%% Helpers %% +%%%%%%%%%%%%%%%%%%%%%%%% + +request(get, Path) -> + do_request(get, {url(Path), []}); +request(post, Path) -> + do_request(post, {url(Path), [], "text/plain", ""}). + +do_request(Method, Request) -> + {ok, {{_, Status, _}, _Headers, Body}} = httpc:request(Method, Request, [], []), + {Status, Body}. + +url(Path) -> + "http://127.0.0.1:" ++ integer_to_list(?PORT) ++ Path. diff --git a/test/test_loop_handler.erl b/test/test_loop_handler.erl new file mode 100644 index 0000000..44b8c5a --- /dev/null +++ b/test/test_loop_handler.erl @@ -0,0 +1,14 @@ +%%% A plain cowboy_loop handler, routed through nova with protocol => cowboy. +-module(test_loop_handler). +-behaviour(cowboy_loop). + +-export([init/2, info/3]). + +init(Req, State) -> + erlang:send_after(50, self(), reply), + {cowboy_loop, Req, State}. + +info(reply, Req, State) -> + Body = maps:get(body, State, <<"loop-ok">>), + Req1 = cowboy_req:reply(200, #{<<"content-type">> => <<"text/plain">>}, Body, Req), + {stop, Req1, State}. diff --git a/test/test_plain_handler.erl b/test/test_plain_handler.erl new file mode 100644 index 0000000..302dcae --- /dev/null +++ b/test/test_plain_handler.erl @@ -0,0 +1,10 @@ +%%% A plain cowboy handler (no sub-protocol), routed through nova with +%%% protocol => cowboy. +-module(test_plain_handler). +-behaviour(cowboy_handler). + +-export([init/2]). + +init(Req0, State) -> + Req = cowboy_req:reply(200, #{<<"content-type">> => <<"text/plain">>}, <<"plain-ok">>, Req0), + {ok, Req, State}. diff --git a/test/test_stream_handler.erl b/test/test_stream_handler.erl new file mode 100644 index 0000000..7c17fb6 --- /dev/null +++ b/test/test_stream_handler.erl @@ -0,0 +1,19 @@ +%%% A cowboy_loop handler streaming its response with +%%% cowboy_req:stream_reply/3 and cowboy_req:stream_body/3. +-module(test_stream_handler). +-behaviour(cowboy_loop). + +-export([init/2, info/3]). + +init(Req0, State) -> + Req = cowboy_req:stream_reply(200, #{<<"content-type">> => <<"text/plain">>}, Req0), + self() ! {chunk, 1}, + {cowboy_loop, Req, State}. + +info({chunk, N}, Req, State) when N =< 3 -> + ok = cowboy_req:stream_body(io_lib:format("chunk~B", [N]), nofin, Req), + erlang:send_after(20, self(), {chunk, N + 1}), + {ok, Req, State}; +info({chunk, _}, Req, State) -> + ok = cowboy_req:stream_body(<<"done">>, fin, Req), + {stop, Req, State}.