From 836342abb86b3ff15d1c8319a455d776f7027a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hoguin?= Date: Wed, 1 Nov 2017 16:27:26 +0000 Subject: Add {switch_handler, Module} return value to cowboy_rest Also {switch_handler, Module, Opts}. Allows switching to a different handler type. This is particularly useful for processing most of the request with cowboy_rest and then streaming the response body using cowboy_loop. --- doc/src/guide/rest_handlers.asciidoc | 8 +++- doc/src/manual/cowboy_rest.asciidoc | 13 ++++++- src/cowboy_rest.erl | 75 ++++++++++++++++++++++++++++++++---- test/handlers/switch_handler_h.erl | 36 +++++++++++++++++ test/rest_handler_SUITE.erl | 70 +++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 test/handlers/switch_handler_h.erl create mode 100644 test/rest_handler_SUITE.erl diff --git a/doc/src/guide/rest_handlers.asciidoc b/doc/src/guide/rest_handlers.asciidoc index c69f02b..dab5bea 100644 --- a/doc/src/guide/rest_handlers.asciidoc +++ b/doc/src/guide/rest_handlers.asciidoc @@ -43,8 +43,12 @@ you need. All callbacks take two arguments, the Req object and the State, and return a three-element tuple of the form `{Value, Req, State}`. -All callbacks can also return `{stop, Req, State}` to stop execution -of the request. +Nearly all callbacks can also return `{stop, Req, State}` to +stop execution of the request, and +`{{switch_handler, Module}, Req, State}` or +`{{switch_handler, Module, Opts}, Req, State}` to switch to +a different handler type. The exceptions are `expires` +`generate_etag`, `last_modified` and `variances`. The following table summarizes the callbacks and their default values. If the callback isn't defined, then the default value will be used. diff --git a/doc/src/manual/cowboy_rest.asciidoc b/doc/src/manual/cowboy_rest.asciidoc index 2fabdce..db048e1 100644 --- a/doc/src/manual/cowboy_rest.asciidoc +++ b/doc/src/manual/cowboy_rest.asciidoc @@ -25,11 +25,15 @@ init(Req, State) Callback(Req, State) -> {Result, Req, State} | {stop, Req, State} + | {{switch_handler, Module}, Req, State} + | {{switch_handler, Module, Opts}, Req, State} terminate(Reason, Req, State) -> ok %% optional Req :: cowboy_req:req() State :: any() +Module :: module() +Opts :: any() Reason :: normal | {crash, error | exit | throw, any()} @@ -51,7 +55,14 @@ implemented. They otherwise all follow the same interface. The `stop` tuple can be returned to stop REST processing. If no response was sent before then, Cowboy will send a -'204 No Content'. +'204 No Content'. The `stop` tuple can be returned from +any callback, excluding `expires`, `generate_etag`, +`last_modified` and `variances`. + +A `switch_handler` tuple can be returned from these same +callbacks to stop REST processing and switch to a different +handler type. This is very useful to, for example, to stream +the response body. The optional `terminate/3` callback will ultimately be called with the reason for the termination of the handler. diff --git a/src/cowboy_rest.erl b/src/cowboy_rest.erl index 21d56a5..abec209 100644 --- a/src/cowboy_rest.erl +++ b/src/cowboy_rest.erl @@ -20,6 +20,9 @@ -export([upgrade/4]). -export([upgrade/5]). +-type switch_handler() :: {switch_handler, module()} + | {switch_handler, module(), any()}. + %% Common handler callbacks. -callback init(Req, any()) @@ -35,162 +38,181 @@ -callback allowed_methods(Req, State) -> {[binary()], Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([allowed_methods/2]). -callback allow_missing_post(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([allow_missing_post/2]). -callback charsets_provided(Req, State) -> {[binary()], Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([charsets_provided/2]). -callback content_types_accepted(Req, State) -> {[{binary() | {binary(), binary(), '*' | [{binary(), binary()}]}, atom()}], Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([content_types_accepted/2]). -callback content_types_provided(Req, State) -> {[{binary() | {binary(), binary(), '*' | [{binary(), binary()}]}, atom()}], Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([content_types_provided/2]). -callback delete_completed(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([delete_completed/2]). -callback delete_resource(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([delete_resource/2]). -callback expires(Req, State) -> {calendar:datetime() | binary() | undefined, Req, State} - | {stop, Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([expires/2]). -callback forbidden(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([forbidden/2]). -callback generate_etag(Req, State) -> {binary() | {weak | strong, binary()}, Req, State} - | {stop, Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([generate_etag/2]). -callback is_authorized(Req, State) -> {true | {false, iodata()}, Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([is_authorized/2]). -callback is_conflict(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([is_conflict/2]). -callback known_methods(Req, State) -> {[binary()], Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([known_methods/2]). -callback languages_provided(Req, State) -> {[binary()], Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([languages_provided/2]). -callback last_modified(Req, State) -> {calendar:datetime(), Req, State} - | {stop, Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([last_modified/2]). -callback malformed_request(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([malformed_request/2]). -callback moved_permanently(Req, State) -> {{true, iodata()} | false, Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([moved_permanently/2]). -callback moved_temporarily(Req, State) -> {{true, iodata()} | false, Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([moved_temporarily/2]). -callback multiple_choices(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([multiple_choices/2]). -callback options(Req, State) -> {ok, Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([options/2]). -callback previously_existed(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([previously_existed/2]). -callback resource_exists(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([resource_exists/2]). -callback service_available(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([service_available/2]). -callback uri_too_long(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([uri_too_long/2]). -callback valid_content_headers(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([valid_content_headers/2]). -callback valid_entity_length(Req, State) -> {boolean(), Req, State} | {stop, Req, State} + | {switch_handler(), Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([valid_entity_length/2]). -callback variances(Req, State) -> {[binary()], Req, State} - | {stop, Req, State} when Req::cowboy_req:req(), State::any(). -optional_callbacks([variances/2]). @@ -233,11 +255,17 @@ -spec upgrade(Req, Env, module(), any()) -> {ok, Req, Env} when Req::cowboy_req:req(), Env::cowboy_middleware:env(). -upgrade(Req0, Env, Handler, HandlerState) -> +upgrade(Req0, Env, Handler, HandlerState0) -> Method = cowboy_req:method(Req0), - {ok, Req, Result} = service_available(Req0, #state{method=Method, - handler=Handler, handler_state=HandlerState}), - {ok, Req, Env#{result => Result}}. + case service_available(Req0, #state{method=Method, + handler=Handler, handler_state=HandlerState0}) of + {ok, Req, Result} -> + {ok, Req, Env#{result => Result}}; + {Mod, Req, HandlerState} -> + Mod:upgrade(Req, Env, Handler, HandlerState); + {Mod, Req, HandlerState, Opts} -> + Mod:upgrade(Req, Env, Handler, HandlerState, Opts) + end. -spec upgrade(Req, Env, module(), any(), any()) -> {ok, Req, Env} when Req::cowboy_req:req(), Env::cowboy_middleware:env(). @@ -260,6 +288,8 @@ known_methods(Req, State=#state{method=Method}) -> next(Req, State, 501); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {List, Req2, HandlerState} -> State2 = State#state{handler_state=HandlerState}, case lists:member(Method, List) of @@ -285,6 +315,8 @@ allowed_methods(Req, State=#state{method=Method}) -> [<<"HEAD">>, <<"GET">>, <<"OPTIONS">>]); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {List, Req2, HandlerState} -> State2 = State#state{handler_state=HandlerState}, case lists:member(Method, List) of @@ -316,6 +348,8 @@ is_authorized(Req, State) -> forbidden(Req, State); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {true, Req2, HandlerState} -> forbidden(Req2, State#state{handler_state=HandlerState}); {{false, AuthHead}, Req2, HandlerState} -> @@ -346,6 +380,8 @@ options(Req, State=#state{allowed_methods=Methods, method= <<"OPTIONS">>}) -> = << << ", ", M/binary >> || M <- Methods >>, Req2 = cowboy_req:set_resp_header(<<"allow">>, Allow, Req), respond(Req2, State, 200); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); {ok, Req2, HandlerState} -> @@ -387,6 +423,8 @@ content_types_provided(Req, State) -> end; {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {[], Req2, HandlerState} -> not_acceptable(Req2, State#state{handler_state=HandlerState}); {CTP, Req2, HandlerState} -> @@ -489,6 +527,8 @@ languages_provided(Req, State) -> charsets_provided(Req, State); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {[], Req2, HandlerState} -> not_acceptable(Req2, State#state{handler_state=HandlerState}); {LP, Req2, HandlerState} -> @@ -549,6 +589,8 @@ charsets_provided(Req, State) -> set_content_type(Req, State); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {[], Req2, HandlerState} -> not_acceptable(Req2, State#state{handler_state=HandlerState}); {CP, Req2, HandlerState} -> @@ -832,6 +874,8 @@ moved_permanently(Req, State, OnFalse) -> OnFalse(Req2, State#state{handler_state=HandlerState}); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); no_call -> OnFalse(Req, State) end. @@ -853,6 +897,8 @@ moved_temporarily(Req, State) -> is_post_to_missing_resource(Req2, State#state{handler_state=HandlerState}, 410); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); no_call -> is_post_to_missing_resource(Req, State, 410) end. @@ -903,6 +949,8 @@ accept_resource(Req, State) -> respond(Req, State, 415); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {CTA, Req2, HandlerState} -> CTA2 = [normalize_content_types(P) || P <- CTA], State2 = State#state{handler_state=HandlerState}, @@ -938,6 +986,8 @@ process_content_type(Req, State=#state{method=Method, exists=Exists}, Fun) -> try case call(Req, State, Fun) of {stop, Req2, HandlerState2} -> terminate(Req2, State#state{handler_state=HandlerState2}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {true, Req2, HandlerState2} when Exists -> State2 = State#state{handler_state=HandlerState2}, next(Req2, State2, fun has_resp_body/2); @@ -1019,6 +1069,8 @@ set_resp_body(Req, State=#state{content_type_a={_, Callback}}) -> try case call(Req, State, Callback) of {stop, Req2, HandlerState2} -> terminate(Req2, State#state{handler_state=HandlerState2}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {Body, Req2, HandlerState2} -> State2 = State#state{handler_state=HandlerState2}, Req3 = cowboy_req:set_resp_body(Body, Req2), @@ -1114,6 +1166,8 @@ expect(Req, State, Callback, Expected, OnTrue, OnFalse) -> next(Req, State, OnTrue); {stop, Req2, HandlerState} -> terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); {Expected, Req2, HandlerState} -> next(Req2, State#state{handler_state=HandlerState}, OnTrue); {_Unexpected, Req2, HandlerState} -> @@ -1148,6 +1202,11 @@ next(Req, State, StatusCode) when is_integer(StatusCode) -> respond(Req, State, StatusCode) -> terminate(cowboy_req:reply(StatusCode, Req), State). +switch_handler({switch_handler, Mod}, Req, HandlerState) -> + {Mod, Req, HandlerState}; +switch_handler({switch_handler, Mod, Opts}, Req, HandlerState) -> + {Mod, Req, HandlerState, Opts}. + -spec error_terminate(cowboy_req:req(), #state{}, atom(), any()) -> no_return(). error_terminate(Req, #state{handler=Handler, handler_state=HandlerState}, Class, Reason) -> cowboy_handler:terminate({crash, Class, Reason}, Req, HandlerState, Handler), diff --git a/test/handlers/switch_handler_h.erl b/test/handlers/switch_handler_h.erl new file mode 100644 index 0000000..f1580d1 --- /dev/null +++ b/test/handlers/switch_handler_h.erl @@ -0,0 +1,36 @@ +-module(switch_handler_h). + +-export([init/2]). +-export([content_types_provided/2]). +-export([provide/2]). +-export([info/3]). + +init(Req, State) -> + {cowboy_rest, Req, State}. + +content_types_provided(Req, State) -> + {[{<<"text/plain">>, provide}], Req, State}. + +provide(Req0, run) -> + Req = cowboy_req:stream_reply(200, Req0), + send_after(0), + {{switch_handler, cowboy_loop}, Req, 0}; +provide(Req0, hibernate) -> + Req = cowboy_req:stream_reply(200, Req0), + send_after(0), + {{switch_handler, cowboy_loop, hibernate}, Req, 0}. + +send_after(N) -> + erlang:send_after(100, self(), {stream, msg(N)}). + +msg(0) -> <<"Hello\n">>; +msg(1) -> <<"streamed\n">>; +msg(2) -> <<"world!\n">>; +msg(3) -> stop. + +info({stream, stop}, Req, State) -> + {stop, Req, State}; +info({stream, What}, Req, State) -> + cowboy_req:stream_body(What, nofin, Req), + send_after(State + 1), + {ok, Req, State + 1}. diff --git a/test/rest_handler_SUITE.erl b/test/rest_handler_SUITE.erl new file mode 100644 index 0000000..d48690b --- /dev/null +++ b/test/rest_handler_SUITE.erl @@ -0,0 +1,70 @@ +%% Copyright (c) 2017, Loïc Hoguin +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(rest_handler_SUITE). +-compile(export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). +-import(cowboy_test, [gun_open/1]). + +%% ct. + +all() -> + cowboy_test:common_all(). + +groups() -> + cowboy_test:common_groups(ct_helper:all(?MODULE)). + +init_per_group(Name, Config) -> + cowboy_test:init_common_groups(Name, Config, ?MODULE). + +end_per_group(Name, _) -> + cowboy:stop_listener(Name). + +%% Dispatch configuration. + +init_dispatch(_) -> + cowboy_router:compile([{'_', [ + {"/switch_handler", switch_handler_h, run}, + {"/switch_handler_opts", switch_handler_h, hibernate} + ]}]). + +%% Internal. + +do_decode(Headers, Body) -> + case lists:keyfind(<<"content-encoding">>, 1, Headers) of + {_, <<"gzip">>} -> zlib:gunzip(Body); + _ -> Body + end. + +%% Tests. + +switch_handler(Config) -> + doc("Switch REST to loop handler for streaming the response body."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/switch_handler", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, nofin, 200, Headers} = gun:await(ConnPid, Ref), + {ok, Body} = gun:await_body(ConnPid, Ref), + <<"Hello\nstreamed\nworld!\n">> = do_decode(Headers, Body), + ok. + +switch_handler_opts(Config) -> + doc("Switch REST to loop handler for streaming the response body; with options."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/switch_handler_opts", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, nofin, 200, Headers} = gun:await(ConnPid, Ref), + {ok, Body} = gun:await_body(ConnPid, Ref), + <<"Hello\nstreamed\nworld!\n">> = do_decode(Headers, Body), + ok. -- cgit v1.2.3