diff options
-rw-r--r-- | src/cowboy_http_rest.erl | 758 | ||||
-rw-r--r-- | test/http_SUITE.erl | 21 | ||||
-rw-r--r-- | test/rest_simple_resource.erl | 12 |
3 files changed, 789 insertions, 2 deletions
diff --git a/src/cowboy_http_rest.erl b/src/cowboy_http_rest.erl new file mode 100644 index 0000000..9bb66fa --- /dev/null +++ b/src/cowboy_http_rest.erl @@ -0,0 +1,758 @@ +%% Copyright (c) 2011, Loïc Hoguin <[email protected]> +%% +%% 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. + +%% @doc Experimental REST protocol implementation. +%% +%% Based on the Webmachine Diagram from Alan Dean and Justin Sheehy, which +%% can be found in the Webmachine source tree, and on the Webmachine +%% documentation available at http://wiki.basho.com/Webmachine.html +%% at the time of writing. +-module(cowboy_http_rest). +-export([upgrade/4]). + +-record(state, { + %% Handler. + handler :: atom(), + handler_state :: any(), + + %% Media type. + content_types_p = [] :: + [{{binary(), binary(), [{binary(), binary()}]}, atom()}], + content_type_a :: undefined + | {{binary(), binary(), [{binary(), binary()}]}, atom()}, + + %% Language. + languages_p = [] :: [binary()], + language_a :: undefined | binary(), + + %% Charset. + charsets_p = [] :: [binary()], + charset_a :: undefined | binary(), + + %% Cached resource calls. + etag :: undefined | no_call | binary(), + last_modified :: undefined | no_call | cowboy_clock:datetime(), + expires :: undefined | no_call | cowboy_clock:datetime() +}). + +-include("include/http.hrl"). + +%% @doc Upgrade a HTTP request to the REST protocol. +%% +%% You do not need to call this function manually. To upgrade to the REST +%% protocol, you simply need to return <em>{upgrade, protocol, {@module}}</em> +%% in your <em>cowboy_http_handler:init/3</em> handler function. +-spec upgrade(pid(), module(), any(), #http_req{}) -> ok. +upgrade(_ListenerPid, Handler, Opts, Req) -> + try + case erlang:function_exported(Handler, rest_init, 2) of + true -> + case Handler:rest_init(Req, Opts) of + {ok, Req2, HandlerState} -> + service_available(Req2, #state{handler=Handler, + handler_state=HandlerState}) + end; + false -> + service_available(Req, #state{handler=Handler}) + end + catch Class:Reason -> + error_logger:error_msg( + "** Handler ~p terminating in rest_init/3~n" + " for the reason ~p:~p~n** Options were ~p~n" + "** Request was ~p~n** Stacktrace: ~p~n~n", + [Handler, Class, Reason, Opts, Req, erlang:get_stacktrace()]), + {ok, _Req2} = cowboy_http_req:reply(500, Req), + ok + end. + +service_available(Req, State) -> + expect(Req, State, service_available, true, fun known_methods/2, 503). + +known_methods(Req=#http_req{method=Method}, State) -> + case call(Req, State, known_methods) of + no_call -> + next(Req, State, fun uri_too_long/2); + {List, Req2, HandlerState2} -> + State2 = State#state{handler_state=HandlerState2}, + case lists:member(Method, List) of + true -> next(Req2, State2, fun uri_too_long/2); + false -> next(Req2, State2, 501) + end + end. + +uri_too_long(Req, State) -> + expect(Req, State, uri_too_long, false, fun allowed_methods/2, 414). + +allowed_methods(Req=#http_req{method=Method}, State) -> + case call(Req, State, allowed_methods) of + no_call -> + next(Req, State, fun malformed_request/2); + {List, Req2, HandlerState2} -> + State2 = State#state{handler_state=HandlerState2}, + case lists:member(Method, List) of + true -> next(Req2, State2, fun malformed_request/2); + false -> method_not_allowed(Req2, State2, List) + end + end. + +method_not_allowed(Req, State, Methods) -> + {ok, Req2} = cowboy_http_req:set_resp_header( + <<"Allow">>, method_not_allowed_build(Methods, []), Req), + respond(Req2, State, 405). + +method_not_allowed_build([], []) -> + <<>>; +method_not_allowed_build([], [_Ignore|Acc]) -> + lists:reverse(Acc); +method_not_allowed_build([Method|Tail], Acc) -> + Method2 = list_to_binary(atom_to_list(Method)), + method_not_allowed_build(Tail, [<<", ">>, Method2|Acc]). + +malformed_request(Req, State) -> + expect(Req, State, malformed_request, false, fun is_authorized/2, 400). + +is_authorized(Req, State) -> + case call(Req, State, is_authorized) of + no_call -> + forbidden(Req, State); + {true, Req2, HandlerState2} -> + forbidden(Req2, State#state{handler_state=HandlerState2}); + {{false, AuthHead}, Req2, HandlerState2} -> + {ok, Req3} = cowboy_http_req:set_resp_header( + <<"Www-Authenticate">>, AuthHead, Req2), + respond(Req3, State#state{handler_state=HandlerState2}, 401) + end. + +forbidden(Req, State) -> + expect(Req, State, forbidden, false, fun valid_content_headers/2, 403). + +valid_content_headers(Req, State) -> + expect(Req, State, valid_content_headers, true, + fun known_content_type/2, 501). + +known_content_type(Req, State) -> + expect(Req, State, known_content_type, true, + fun valid_entity_length/2, 413). + +valid_entity_length(Req, State) -> + expect(Req, State, valid_entity_length, true, fun options/2, 413). + +options(Req=#http_req{method='OPTIONS'}, State) -> + {ok, Req2, HandlerState2} = call(Req, State, options), + respond(Req2, State#state{handler_state=HandlerState2}, 200); +options(Req, State) -> + content_types_provided(Req, State). + +%% The content_types_provided function MUST be defined in the resource +%% from this point onward. +content_types_provided(Req, State) -> + case call(Req, State, content_types_provided) of + no_call -> + not_acceptable(Req, State); + {[], Req2, HandlerState2} -> + not_acceptable(Req2, State#state{handler_state=HandlerState2}); + {CTP, Req2, HandlerState2} -> + State2 = State#state{handler_state=HandlerState2, content_types_p=CTP}, + {Accept, Req3} = cowboy_http_req:parse_header('Accept', Req2), + case Accept of + undefined -> + languages_provided(Req3, + State2#state{content_type_a=hd(CTP)}); + Accept -> + Accept2 = prioritize_accept(Accept), + choose_media_type(Req3, State2, Accept2) + end + end. + +prioritize_accept(Accept) -> + lists:sort( + fun ({MediaTypeA, Quality, _AcceptParamsA}, + {MediaTypeB, Quality, _AcceptParamsB}) -> + %% Same quality, check precedence in more details. + prioritize_mediatype(MediaTypeA, MediaTypeB); + ({_MediaTypeA, QualityA, _AcceptParamsA}, + {_MediaTypeB, QualityB, _AcceptParamsB}) -> + %% Just compare the quality. + QualityA > QualityB + end, Accept). + +%% Media ranges can be overridden by more specific media ranges or +%% specific media types. If more than one media range applies to a given +%% type, the most specific reference has precedence. +%% +%% We always choose B over A when we can't decide between the two. +prioritize_mediatype({TypeA, SubTypeA, ParamsA}, {TypeB, SubTypeB, ParamsB}) -> + case TypeB of + TypeA -> + case SubTypeB of + SubTypeA -> length(ParamsA) > length(ParamsB); + <<"*">> -> true; + _Any -> false + end; + <<"*">> -> true; + _Any -> false + end. + +%% Ignoring the rare AcceptParams. Not sure what should be done about them. +choose_media_type(Req, State, []) -> + not_acceptable(Req, State); +choose_media_type(Req, State=#state{content_types_p=CTP}, + [MediaType|Tail]) -> + match_media_type(Req, State, Tail, CTP, MediaType). + +match_media_type(Req, State, Accept, [], _MediaType) -> + choose_media_type(Req, State, Accept); +match_media_type(Req, State, Accept, + [Provided = {{Type, SubType_P, Params_P}, _Fun}|Tail], + MediaType = {{Type, SubType_A, Params_A}, _Quality, _AcceptParams}) + when SubType_P =:= SubType_A; SubType_A =:= <<"*">> -> + case lists:sort(Params_P) =:= lists:sort(Params_A) of + true -> + languages_provided(Req, State#state{content_type_a=Provided}); + false -> + match_media_type(Req, State, Accept, Tail, MediaType) + end; +match_media_type(Req, State, Accept, [_Any|Tail], MediaType) -> + match_media_type(Req, State, Accept, Tail, MediaType). + +%% @todo I suppose we should also ask the resource if it wants to +%% set a language itself or if it wants it to be automatically chosen. +languages_provided(Req, State) -> + case call(Req, State, languages_provided) of + no_call -> + charsets_provided(Req, State); + {[], Req2, HandlerState2} -> + not_acceptable(Req2, State#state{handler_state=HandlerState2}); + {LP, Req2, HandlerState2} -> + State2 = State#state{handler_state=HandlerState2, languages_p=LP}, + {AcceptLanguage, Req3} = + cowboy_http_req:parse_header('Accept-Language', Req2), + case AcceptLanguage of + undefined -> + set_language(Req3, State2#state{language_a=hd(LP)}); + AcceptLanguage -> + AcceptLanguage2 = prioritize_languages(AcceptLanguage), + choose_language(Req3, State2, AcceptLanguage2) + end + end. + +%% A language-range matches a language-tag if it exactly equals the tag, +%% or if it exactly equals a prefix of the tag such that the first tag +%% character following the prefix is "-". The special range "*", if +%% present in the Accept-Language field, matches every tag not matched +%% by any other range present in the Accept-Language field. +%% +%% @todo The last sentence probably means we should always put '*' +%% at the end of the list. +prioritize_languages(AcceptLanguages) -> + lists:sort( + fun ({_TagA, QualityA}, {_TagB, QualityB}) -> + QualityA > QualityB + end, AcceptLanguages). + +choose_language(Req, State, []) -> + not_acceptable(Req, State); +choose_language(Req, State=#state{languages_p=LP}, [Language|Tail]) -> + match_language(Req, State, Tail, LP, Language). + +match_language(Req, State, Accept, [], _Language) -> + choose_language(Req, State, Accept); +match_language(Req, State, _Accept, [Provided|_Tail], {'*', _Quality}) -> + set_language(Req, State#state{language_a=Provided}); +match_language(Req, State, _Accept, [Provided|_Tail], {Provided, _Quality}) -> + set_language(Req, State#state{language_a=Provided}); +match_language(Req, State, Accept, [Provided|Tail], + Language = {Tag, _Quality}) -> + Length = byte_size(Tag), + case Provided of + << Tag:Length/binary, $-, _Any/bits >> -> + set_language(Req, State#state{language_a=Provided}); + _Any -> + match_language(Req, State, Accept, Tail, Language) + end. + +set_language(Req, State=#state{language_a=Language}) -> + {ok, Req2} = cowboy_http_req:set_resp_header( + <<"Content-Language">>, Language, Req), + charsets_provided(Req2, State). + +charsets_provided(Req, State) -> + case call(Req, State, charsets_provided) of + no_call -> + set_content_type(Req, State); + {[], Req2, HandlerState2} -> + not_acceptable(Req2, State#state{handler_state=HandlerState2}); + {CP, Req2, HandlerState2} -> + State2 = State#state{handler_state=HandlerState2, charsets_p=CP}, + {AcceptCharset, Req3} = + cowboy_http_req:parse_header('Accept-Charset', Req2), + case AcceptCharset of + undefined -> + set_content_type(Req3, State2#state{charset_a=hd(CP)}); + AcceptCharset -> + AcceptCharset2 = prioritize_charsets(AcceptCharset), + choose_charset(Req3, State2, AcceptCharset2) + end + end. + +%% The special value "*", if present in the Accept-Charset field, +%% matches every character set (including ISO-8859-1) which is not +%% mentioned elsewhere in the Accept-Charset field. If no "*" is present +%% in an Accept-Charset field, then all character sets not explicitly +%% mentioned get a quality value of 0, except for ISO-8859-1, which gets +%% a quality value of 1 if not explicitly mentioned. +prioritize_charsets(AcceptCharsets) -> + AcceptCharsets2 = lists:sort( + fun ({_CharsetA, QualityA}, {_CharsetB, QualityB}) -> + QualityA > QualityB + end, AcceptCharsets), + case lists:keymember(<<"*">>, 1, AcceptCharsets2) of + true -> AcceptCharsets2; + false -> [{<<"iso-8859-1">>, 1000}|AcceptCharsets2] + end. + +choose_charset(Req, State, []) -> + not_acceptable(Req, State); +choose_charset(Req, State=#state{charsets_p=CP}, [Charset|Tail]) -> + match_charset(Req, State, Tail, CP, Charset). + +match_charset(Req, State, Accept, [], _Charset) -> + choose_charset(Req, State, Accept); +match_charset(Req, State, _Accept, [Provided|_Tail], + {Provided, _Quality}) -> + set_content_type(Req, State#state{charset_a=Provided}); +match_charset(Req, State, Accept, [_Provided|Tail], Charset) -> + match_charset(Req, State, Accept, Tail, Charset). + +set_content_type(Req, State=#state{ + content_type_a={{Type, SubType, Params}, _Fun}, + charset_a=Charset}) -> + ParamsBin = set_content_type_build_params(Params, []), + ContentType = [Type, <<"/">>, SubType, ParamsBin], + ContentType2 = case Charset of + undefined -> ContentType; + Charset -> [ContentType, <<"; charset=">>, Charset] + end, + {ok, Req2} = cowboy_http_req:set_resp_header( + <<"Content-Type">>, ContentType2, Req), + encodings_provided(Req2, State). + +set_content_type_build_params([], []) -> + <<>>; +set_content_type_build_params([], Acc) -> + lists:reverse(Acc); +set_content_type_build_params([{Attr, Value}|Tail], Acc) -> + set_content_type_build_params(Tail, [[Attr, <<"=">>, Value], <<";">>|Acc]). + +%% @todo Match for identity as we provide nothing else for now. +%% @todo Don't forget to set the Content-Encoding header when we reply a body +%% and the found encoding is something other than identity. +encodings_provided(Req, State) -> + variances(Req, State). + +not_acceptable(Req, State) -> + respond(Req, State, 406). + +%% @todo Do Accept-Encoding too when we handle it. +%% @todo Does the order matter? +variances(Req, State=#state{content_types_p=CTP, + languages_p=LP, charsets_p=CP}) -> + Variances = case length(CTP) of + 0 -> []; + 1 -> []; + _NCT -> [<<"Accept">>] + end, + Variances2 = case length(LP) of + 0 -> Variances; + 1 -> Variances; + _NL -> [<<"Accept-Language">>|Variances] + end, + Variances3 = case length(CP) of + 0 -> Variances2; + 1 -> Variances2; + _NC -> [<<"Accept-Charset">>|Variances2] + end, + {Variances4, Req3, State2} = case call(Req, State, variances) of + no_call -> + {Variances3, Req, State}; + {HandlerVariances, Req2, HandlerState} -> + {Variances3 ++ HandlerVariances, Req2, + State#state{handler_state=HandlerState}} + end, + case lists:flatten([[<<", ">>, V] || V <- Variances4]) of + [] -> + resource_exists(Req3, State2); + [<<", ">>, Variances5] -> + {ok, Req4} = cowboy_http_req:set_resp_header( + <<"Variances">>, Variances5, Req3), + resource_exists(Req4, State2) + end. + +resource_exists(Req, State) -> + expect(Req, State, resource_exists, true, + fun if_match_exists/2, fun if_match_musnt_exist/2). + +if_match_exists(Req, State) -> + case cowboy_http_req:parse_header('If-Match', Req) of + {undefined, Req2} -> + if_unmodified_since_exists(Req2, State); + {'*', Req2} -> + if_unmodified_since_exists(Req2, State); + {ETagsList, Req2} -> + if_match(Req2, State, ETagsList) + end. + +if_match(Req, State, EtagsList) -> + {Etag, Req2, State2} = generate_etag(Req, State), + case Etag of + no_call -> + precondition_failed(Req2, State2); + Etag -> + case lists:member(Etag, EtagsList) of + true -> if_unmodified_since_exists(Req2, State2); + false -> precondition_failed(Req2, State2) + end + end. + +if_match_musnt_exist(Req, State) -> + case cowboy_http_req:header('If-Match', Req) of + {undefined, Req2} -> is_put_to_missing_resource(Req2, State); + {_Any, Req2} -> precondition_failed(Req2, State) + end. + +if_unmodified_since_exists(Req, State) -> + case cowboy_http_req:parse_header('If-Unmodified-Since', Req) of + {undefined, Req2} -> + if_none_match_exists(Req2, State); + {{error, badarg}, Req2} -> + if_none_match_exists(Req2, State); + {IfUnmodifiedSince, Req2} -> + if_unmodified_since(Req2, State, IfUnmodifiedSince) + end. + +%% If LastModified is the atom 'no_call', we continue. +if_unmodified_since(Req, State, IfUnmodifiedSince) -> + {LastModified, Req2, State2} = last_modified(Req, State), + case LastModified > IfUnmodifiedSince of + true -> precondition_failed(Req2, State2); + false -> if_none_match_exists(Req2, State2) + end. + +if_none_match_exists(Req, State) -> + case cowboy_http_req:parse_header('If-None-Match', Req) of + {undefined, Req2} -> + if_modified_since_exists(Req2, State); + {'*', Req2} -> + precondition_is_head_get(Req2, State); + {EtagsList, Req2} -> + if_none_match(Req2, State, EtagsList) + end. + +if_none_match(Req, State, EtagsList) -> + {Etag, Req2, State2} = generate_etag(Req, State), + case Etag of + no_call -> + precondition_failed(Req2, State2); + Etag -> + case lists:member(Etag, EtagsList) of + true -> precondition_is_head_get(Req2, State2); + false -> if_modified_since_exists(Req2, State2) + end + end. + +precondition_is_head_get(Req=#http_req{method=Method}, State) + when Method =:= 'HEAD'; Method =:= 'GET' -> + not_modified(Req, State); +precondition_is_head_get(Req, State) -> + precondition_failed(Req, State). + +if_modified_since_exists(Req, State) -> + case cowboy_http_req:parse_header('If-Modified-Since', Req) of + {undefined, Req2} -> + method(Req2, State); + {{error, badarg}, Req2} -> + method(Req2, State); + {IfModifiedSince, Req2} -> + if_modified_since_now(Req2, State, IfModifiedSince) + end. + +if_modified_since_now(Req, State, IfModifiedSince) -> + case IfModifiedSince > erlang:universaltime() of + true -> method(Req, State); + false -> if_modified_since(Req, State, IfModifiedSince) + end. + +if_modified_since(Req, State, IfModifiedSince) -> + {LastModified, Req2, State2} = last_modified(Req, State), + case LastModified of + no_call -> + method(Req2, State2); + LastModified -> + case LastModified > IfModifiedSince of + true -> method(Req2, State2); + false -> not_modified(Req2, State2) + end + end. + +not_modified(Req=#http_req{resp_headers=RespHeaders}, State) -> + RespHeaders2 = lists:keydelete(<<"Content-Type">>, 1, RespHeaders), + Req2 = Req#http_req{resp_headers=RespHeaders2}, + {Req3, State2} = set_resp_etag(Req2, State), + {Req4, State3} = set_resp_expires(Req3, State2), + respond(Req4, State3, 304). + +precondition_failed(Req, State) -> + respond(Req, State, 412). + +is_put_to_missing_resource(Req=#http_req{method='PUT'}, State) -> + moved_permanently(Req, State, fun is_conflict/2); +is_put_to_missing_resource(Req, State) -> + previously_existed(Req, State). + +moved_permanently(Req, State, OnFalse) -> + case call(Req, State, moved_permanently) of + {{true, Location}, Req2, HandlerState2} -> + {ok, Req3} = cowboy_http_req:set_resp_header( + <<"Location">>, Location, Req2), + respond(Req3, State#state{handler_state=HandlerState2}, 301); + {false, Req2, HandlerState2} -> + OnFalse(Req2, State#state{handler_state=HandlerState2}); + no_call -> + OnFalse(Req, State) + end. + +previously_existed(Req, State) -> + expect(Req, State, previously_existed, false, + fun (R, S) -> is_post_to_missing_resource(R, S, 404) end, + fun (R, S) -> moved_permanently(R, S, fun moved_temporarily/2) end). + +moved_temporarily(Req, State) -> + case call(Req, State, moved_temporarily) of + {{true, Location}, Req2, HandlerState2} -> + {ok, Req3} = cowboy_http_req:set_resp_header( + <<"Location">>, Location, Req2), + respond(Req3, State#state{handler_state=HandlerState2}, 307); + {false, Req2, HandlerState2} -> + is_post_to_missing_resource(Req2, State#state{handler_state=HandlerState2}, 410); + no_call -> + is_post_to_missing_resource(Req, State, 410) + end. + +is_post_to_missing_resource(Req=#http_req{method='POST'}, State, OnFalse) -> + allow_missing_post(Req, State, OnFalse); +is_post_to_missing_resource(Req, State, OnFalse) -> + respond(Req, State, OnFalse). + +allow_missing_post(Req, State, OnFalse) -> + expect(Req, State, allow_missing_post, true, fun post_is_create/2, OnFalse). + +method(Req=#http_req{method='DELETE'}, State) -> + delete_resource(Req, State); +method(Req=#http_req{method='POST'}, State) -> + post_is_create(Req, State); +method(Req=#http_req{method='PUT'}, State) -> + is_conflict(Req, State); +method(Req, State) -> + set_resp_body(Req, State). + +delete_resource(Req, State) -> + expect(Req, State, delete_resource, true, fun delete_completed/2, 500). + +delete_completed(Req, State) -> + expect(Req, State, delete_completed, true, fun has_resp_body/2, 202). + +post_is_create(Req, State) -> + expect(Req, State, post_is_create, false, fun process_post/2, fun create_path/2). + +create_path(Req, State) -> + case call(Req, State, create_path) of + {Location, Req2, HandlerState} -> + State2 = State#state{handler_state=HandlerState}, + {ok, Req3} = cowboy_http_req:set_resp_header( + <<"Location">>, Location, Req2), + put_resource(Req3, State2, 303) + end. + +process_post(Req, State) -> + case call(Req, State, process_post) of + {ok, _Req2, HandlerState} -> + _ = _State2 = State#state{handler_state=HandlerState}, + todo %% @todo ??? + end. + +is_conflict(Req, State) -> + expect(Req, State, is_conflict, false, fun put_resource/2, 409). + +put_resource(Req, State) -> + put_resource(Req, State, fun is_new_resource/2). + +put_resource(Req, State, OnTrue) -> + case call(Req, State, content_types_accepted) of + no_call -> + respond(Req, State, 415); + {CTA, Req2, HandlerState2} -> + State2 = State#state{handler_state=HandlerState2}, + {ContentType, Req3} + = cowboy_http_req:parse_header('Content-Type', Req2), + choose_content_type(Req3, State2, OnTrue, ContentType, CTA) + end. + +choose_content_type(Req, State, _OnTrue, _ContentType, []) -> + respond(Req, State, 415); +choose_content_type(Req, State, OnTrue, ContentType, + [{Accepted, Fun}|_Tail]) when ContentType =:= Accepted -> + case call(Req, State, Fun) of + {ok, Req2, HandlerState} -> + State2 = State#state{handler_state=HandlerState}, + next(Req2, State2, OnTrue) + end; +choose_content_type(Req, State, OnTrue, ContentType, [_Any|Tail]) -> + choose_content_type(Req, State, OnTrue, ContentType, Tail). + +is_new_resource(Req, State) -> + case cowboy_http_req:has_resp_header(<<"Location">>, Req) of + true -> respond(Req, State, 201); + false -> has_resp_body(Req, State) + end. + +has_resp_body(Req, State) -> + case cowboy_http_req:has_resp_body(Req) of + true -> multiple_choices(Req, State); + false -> respond(Req, State, 204) + end. + +set_resp_body(Req=#http_req{method=Method}, + State=#state{content_type_a={_Type, Fun}}) + when Method =:= 'GET'; Method =:= 'HEAD' -> + {Req2, State2} = set_resp_etag(Req, State), + {LastModified, Req3, State3} = last_modified(Req2, State2), + case LastModified of + LastModified when is_atom(LastModified) -> + Req4 = Req3; + LastModified -> + LastModifiedStr = httpd_util:rfc1123_date(LastModified), + {ok, Req4} = cowboy_http_req:set_resp_header( + <<"Last-Modified">>, LastModifiedStr, Req3) + end, + {Req5, State4} = set_resp_expires(Req4, State3), + case call(Req5, State4, Fun) of + {Body, Req6, HandlerState} -> + State5 = State4#state{handler_state=HandlerState}, + {ok, Req7} = cowboy_http_req:set_resp_body(Body, Req6), + multiple_choices(Req7, State5) + end; +set_resp_body(Req, State) -> + multiple_choices(Req, State). + +multiple_choices(Req, State) -> + expect(Req, State, multiple_choices, false, 200, 300). + +%% Response utility functions. + +set_resp_etag(Req, State) -> + {Etag, Req2, State2} = generate_etag(Req, State), + case Etag of + undefined -> + {Req2, State2}; + Etag -> + {ok, Req3} = cowboy_http_req:set_resp_header( + <<"Etag">>, Etag, Req2), + {Req3, State2} + end. + +set_resp_expires(Req, State) -> + {Expires, Req2, State2} = expires(Req, State), + case Expires of + Expires when is_atom(Expires) -> + {Req2, State2}; + Expires -> + ExpiresStr = httpd_util:rfc1123_date(Expires), + {ok, Req3} = cowboy_http_req:set_resp_header( + <<"Expires">>, ExpiresStr, Req2), + {Req3, State2} + end. + +%% Info retrieval. No logic. + +generate_etag(Req, State=#state{etag=no_call}) -> + {undefined, Req, State}; +generate_etag(Req, State=#state{etag=undefined}) -> + case call(Req, State, generate_etag) of + no_call -> + {undefined, Req, State#state{etag=no_call}}; + {Etag, Req2, HandlerState2} -> + {Etag, Req2, State#state{handler_state=HandlerState2, etag=Etag}} + end; +generate_etag(Req, State=#state{etag=Etag}) -> + {Etag, Req, State}. + +last_modified(Req, State=#state{last_modified=no_call}) -> + {undefined, Req, State}; +last_modified(Req, State=#state{last_modified=undefined}) -> + case call(Req, State, last_modified) of + no_call -> + {undefined, Req, State#state{last_modified=no_call}}; + {LastModified, Req2, HandlerState2} -> + {LastModified, Req2, State#state{handler_state=HandlerState2, + last_modified=LastModified}} + end; +last_modified(Req, State=#state{last_modified=LastModified}) -> + {LastModified, Req, State}. + +expires(Req, State=#state{expires=no_call}) -> + {undefined, Req, State}; +expires(Req, State=#state{expires=undefined}) -> + case call(Req, State, expires) of + no_call -> + {undefined, Req, State#state{expires=no_call}}; + {Expires, Req2, HandlerState2} -> + {Expires, Req2, State#state{handler_state=HandlerState2, + expires=Expires}} + end; +expires(Req, State=#state{expires=Expires}) -> + {Expires, Req, State}. + +%% REST primitives. + +expect(Req, State, Callback, Expected, OnTrue, OnFalse) -> + case call(Req, State, Callback) of + no_call -> + next(Req, State, OnTrue); + {Expected, Req2, HandlerState2} -> + next(Req2, State#state{handler_state=HandlerState2}, OnTrue); + {_Unexpected, Req2, HandlerState2} -> + next(Req2, State#state{handler_state=HandlerState2}, OnFalse) + end. + +call(Req, #state{handler=Handler, handler_state=HandlerState}, Fun) -> + case erlang:function_exported(Handler, Fun, 2) of + true -> Handler:Fun(Req, HandlerState); + false -> no_call + end. + +next(Req, State, Next) when is_function(Next) -> + Next(Req, State); +next(Req, State, StatusCode) when is_integer(StatusCode) -> + respond(Req, State, StatusCode). + +%% @todo Allow some sort of callback for custom error pages. +respond(Req, State, StatusCode) -> + {ok, Req2} = cowboy_http_req:reply(StatusCode, Req), + terminate(Req2, State). + +terminate(Req, #state{handler=Handler, handler_state=HandlerState}) -> + case erlang:function_exported(Handler, rest_terminate, 2) of + true -> ok = Handler:rest_terminate(Req, HandlerState); + false -> ok + end. diff --git a/test/http_SUITE.erl b/test/http_SUITE.erl index 5f02a7c..eb0bf54 100644 --- a/test/http_SUITE.erl +++ b/test/http_SUITE.erl @@ -25,11 +25,12 @@ set_resp_overwrite/1, set_resp_body/1]). %% http. -export([http_200/1, http_404/1]). %% http and https. -export([http_10_hostless/1]). %% misc. +-export([rest_simple/1]). %% rest. %% ct. all() -> - [{group, http}, {group, https}, {group, misc}]. + [{group, http}, {group, https}, {group, misc}, {group, rest}]. groups() -> BaseTests = [http_200, http_404], @@ -38,7 +39,9 @@ groups() -> ws0, ws8, ws8_single_bytes, ws8_init_shutdown, ws13, ws_timeout_hibernate, set_resp_header, set_resp_overwrite, set_resp_body] ++ BaseTests}, - {https, [], BaseTests}, {misc, [], [http_10_hostless]}]. + {https, [], BaseTests}, + {misc, [], [http_10_hostless]}, + {rest, [], [rest_simple]}]. init_per_suite(Config) -> application:start(inets), @@ -77,6 +80,14 @@ init_per_group(misc, Config) -> cowboy_http_protocol, [{dispatch, [{'_', [ {[], http_handler, []} ]}]}]), + [{port, Port}|Config]; +init_per_group(rest, Config) -> + Port = 33083, + cowboy:start_listener(reset, 100, + cowboy_tcp_transport, [{port, Port}], + cowboy_http_protocol, [{dispatch, [{'_', [ + {[<<"simple">>], rest_simple_resource, []} + ]}]}]), [{port, Port}|Config]. end_per_group(https, _Config) -> @@ -535,3 +546,9 @@ http_404(Config) -> http_10_hostless(Config) -> Packet = "GET / HTTP/1.0\r\n\r\n", {Packet, 200} = raw_req(Packet, Config). + +%% rest. + +rest_simple(Config) -> + Packet = "GET /simple HTTP/1.1\r\nHost: localhost\r\n\r\n", + {Packet, 200} = raw_req(Packet, Config). diff --git a/test/rest_simple_resource.erl b/test/rest_simple_resource.erl new file mode 100644 index 0000000..e2c573c --- /dev/null +++ b/test/rest_simple_resource.erl @@ -0,0 +1,12 @@ +-module(rest_simple_resource). +-export([init/3, content_types_provided/2, get_text_plain/2]). + +init(_Transport, _Req, _Opts) -> + {upgrade, protocol, cowboy_http_rest}. + +content_types_provided(Req, State) -> + {[{{<<"text">>, <<"plain">>, []}, get_text_plain}], Req, State}. + +get_text_plain(Req, State) -> + {<<"This is REST!">>, Req, State}. + |