|
@@ -15,7 +15,7 @@
|
|
|
|
|
|
-module(cowboy_req).
|
|
|
|
|
|
-%% Request API.
|
|
|
+%% Request.
|
|
|
-export([method/1]).
|
|
|
-export([version/1]).
|
|
|
-export([peer/1]).
|
|
@@ -41,109 +41,83 @@
|
|
|
-export([parse_cookies/1]).
|
|
|
-export([match_cookies/2]).
|
|
|
|
|
|
-%% Request body API.
|
|
|
+%% Request body.
|
|
|
-export([has_body/1]).
|
|
|
-export([body_length/1]).
|
|
|
-export([read_body/1]).
|
|
|
-export([read_body/2]).
|
|
|
+-export([read_urlencoded_body/1]).
|
|
|
+-export([read_urlencoded_body/2]).
|
|
|
|
|
|
--export([body/1]).
|
|
|
--export([body/2]).
|
|
|
--export([body_qs/1]).
|
|
|
--export([body_qs/2]).
|
|
|
+%% Multipart.
|
|
|
+-export([part/1]). %% @todo read_part?
|
|
|
+-export([part/2]). %% @todo read_part?
|
|
|
+-export([part_body/1]). %% @todo read_part_body?
|
|
|
+-export([part_body/2]). %% @todo read_part_body?
|
|
|
|
|
|
-%% Multipart API.
|
|
|
--export([part/1]).
|
|
|
--export([part/2]).
|
|
|
--export([part_body/1]).
|
|
|
--export([part_body/2]).
|
|
|
-
|
|
|
-%% Response API.
|
|
|
+%% Response.
|
|
|
+-export([set_resp_cookie/3]).
|
|
|
-export([set_resp_cookie/4]).
|
|
|
-export([set_resp_header/3]).
|
|
|
--export([set_resp_body/2]).
|
|
|
--export([set_resp_body_fun/2]).
|
|
|
--export([set_resp_body_fun/3]).
|
|
|
-export([has_resp_header/2]).
|
|
|
--export([has_resp_body/1]).
|
|
|
+%% @todo resp_header
|
|
|
-export([delete_resp_header/2]).
|
|
|
+-export([set_resp_body/2]). %% @todo Use set_resp_body for iodata() | {sendfile ...}
|
|
|
+-export([has_resp_body/1]).
|
|
|
-export([reply/2]).
|
|
|
-export([reply/3]).
|
|
|
-export([reply/4]).
|
|
|
-
|
|
|
--export([send_body/3]).
|
|
|
-
|
|
|
--export([chunked_reply/2]).
|
|
|
--export([chunked_reply/3]).
|
|
|
--export([chunk/2]).
|
|
|
--export([continue/1]).
|
|
|
--export([maybe_reply/2]).
|
|
|
--export([ensure_response/2]).
|
|
|
+-export([stream_reply/2]).
|
|
|
+-export([stream_reply/3]).
|
|
|
+-export([stream_body/3]).
|
|
|
+-export([push/3]).
|
|
|
+-export([push/4]).
|
|
|
|
|
|
-type cookie_opts() :: cow_cookie:cookie_opts().
|
|
|
-export_type([cookie_opts/0]).
|
|
|
|
|
|
--type content_decode_fun() :: fun((binary()) -> binary()).
|
|
|
--type transfer_decode_fun() :: fun((binary(), any())
|
|
|
- -> cow_http_te:decode_ret()).
|
|
|
-
|
|
|
--type body_opts() :: [{continue, boolean()} %% doesn't apply
|
|
|
- | {length, non_neg_integer()}
|
|
|
- | {read_length, non_neg_integer()} %% to be added back later as optimization
|
|
|
- | {read_timeout, timeout()} %% same
|
|
|
- | {transfer_decode, transfer_decode_fun(), any()} %% doesn't apply
|
|
|
- | {content_decode, content_decode_fun()}]. %% does apply
|
|
|
+-type body_opts() :: #{
|
|
|
+ length => non_neg_integer(),
|
|
|
+ period => non_neg_integer(),
|
|
|
+ timeout => timeout()
|
|
|
+}.
|
|
|
-export_type([body_opts/0]).
|
|
|
|
|
|
--type resp_body_fun() :: fun((any(), module()) -> ok).
|
|
|
--type send_chunk_fun() :: fun((iodata()) -> ok).
|
|
|
--type resp_chunked_fun() :: fun((send_chunk_fun()) -> ok).
|
|
|
-
|
|
|
--record(http_req, {
|
|
|
- %% Transport.
|
|
|
- socket = undefined :: any(),
|
|
|
- transport = undefined :: undefined | module(),
|
|
|
- connection = keepalive :: keepalive | close,
|
|
|
-
|
|
|
- %% Request.
|
|
|
- pid = undefined :: pid(),
|
|
|
- method = <<"GET">> :: binary(),
|
|
|
- version = 'HTTP/1.1' :: cowboy:http_version(),
|
|
|
- peer = undefined :: undefined | {inet:ip_address(), inet:port_number()},
|
|
|
- host = undefined :: undefined | binary(),
|
|
|
- host_info = undefined :: undefined | cowboy_router:tokens(),
|
|
|
- port = undefined :: undefined | inet:port_number(),
|
|
|
- path = undefined :: binary(),
|
|
|
- path_info = undefined :: undefined | cowboy_router:tokens(),
|
|
|
- qs = undefined :: binary(),
|
|
|
- bindings = undefined :: undefined | cowboy_router:bindings(),
|
|
|
- headers = [] :: cowboy:http_headers(),
|
|
|
- meta = [] :: [{atom(), any()}],
|
|
|
-
|
|
|
- %% Request body.
|
|
|
- body_state = waiting :: waiting | done | {stream, non_neg_integer(),
|
|
|
- transfer_decode_fun(), any(), content_decode_fun()},
|
|
|
- buffer = <<>> :: binary(),
|
|
|
- multipart = undefined :: undefined | {binary(), binary()},
|
|
|
-
|
|
|
- %% Response.
|
|
|
- resp_compress = false :: boolean(),
|
|
|
- resp_state = waiting :: locked | waiting | waiting_stream
|
|
|
- | chunks | stream | done,
|
|
|
- resp_headers = [] :: cowboy:http_headers(),
|
|
|
- resp_body = <<>> :: iodata() | resp_body_fun()
|
|
|
- | {non_neg_integer(), resp_body_fun()}
|
|
|
- | {chunked, resp_chunked_fun()},
|
|
|
-
|
|
|
- %% Functions.
|
|
|
- onresponse = undefined :: undefined | already_called
|
|
|
- | cowboy:onresponse_fun()
|
|
|
-}).
|
|
|
-
|
|
|
--opaque req() :: #http_req{}.
|
|
|
+%% While sendfile allows a Len of 0 that means "everything past Offset",
|
|
|
+%% Cowboy expects the real length as it is used as metadata.
|
|
|
+%% @todo We should probably explicitly reject it.
|
|
|
+-type resp_body() :: iodata()
|
|
|
+ | {sendfile, non_neg_integer(), pos_integer(), file:name_all()}.
|
|
|
+-export_type([resp_body/0]).
|
|
|
+
|
|
|
+-type req() :: map(). %% @todo #{
|
|
|
+% ref := ranch:ref(),
|
|
|
+% pid := pid(),
|
|
|
+% streamid := cowboy_stream:streamid(),
|
|
|
+% peer := {inet:ip_address(), inet:port_number()},
|
|
|
+%
|
|
|
+% method := binary(), %% case sensitive
|
|
|
+% version := cowboy:http_version(),
|
|
|
+% scheme := binary(), %% <<"http">> or <<"https">>
|
|
|
+% host := binary(), %% lowercase; case insensitive
|
|
|
+% port := inet:port_number(),
|
|
|
+% path := binary(), %% case sensitive
|
|
|
+% qs := binary(), %% case sensitive
|
|
|
+% headers := cowboy:http_headers(),
|
|
|
+%
|
|
|
+% host_info => cowboy_router:tokens(),
|
|
|
+% path_info => cowboy_router:tokens(),
|
|
|
+% bindings => cowboy_router:bindings(),
|
|
|
+%
|
|
|
+% has_body := boolean(),
|
|
|
+% has_read_body => true,
|
|
|
+% body_length := undefined | non_neg_integer()
|
|
|
+%
|
|
|
+%% @todo resp_*
|
|
|
+%}.
|
|
|
-export_type([req/0]).
|
|
|
|
|
|
-%% Request API.
|
|
|
+%% Request.
|
|
|
|
|
|
-spec method(req()) -> binary().
|
|
|
method(#{method := Method}) ->
|
|
@@ -403,206 +377,66 @@ parse_cookies(Req) ->
|
|
|
match_cookies(Fields, Req) ->
|
|
|
filter(Fields, kvlist_to_map(Fields, parse_cookies(Req))).
|
|
|
|
|
|
-%% Request Body API.
|
|
|
+%% Request body.
|
|
|
|
|
|
-spec has_body(req()) -> boolean().
|
|
|
has_body(#{has_body := HasBody}) ->
|
|
|
HasBody.
|
|
|
|
|
|
-%% The length may not be known if Transfer-Encoding is not identity,
|
|
|
-%% and the body hasn't been read at the time of the call.
|
|
|
+%% The length may not be known if HTTP/1.1 with a transfer-encoding;
|
|
|
+%% or HTTP/2 with no content-length header. The length is always
|
|
|
+%% known once the body has been completely read.
|
|
|
-spec body_length(req()) -> undefined | non_neg_integer().
|
|
|
body_length(#{body_length := Length}) ->
|
|
|
Length.
|
|
|
|
|
|
--spec body(Req) -> {ok, binary(), Req} | {more, binary(), Req} when Req::req().
|
|
|
-body(Req) ->
|
|
|
- body(Req, []).
|
|
|
-
|
|
|
-spec read_body(Req) -> {ok, binary(), Req} | {more, binary(), Req} when Req::req().
|
|
|
read_body(Req) ->
|
|
|
- read_body(Req, []).
|
|
|
+ read_body(Req, #{}).
|
|
|
|
|
|
-spec read_body(Req, body_opts()) -> {ok, binary(), Req} | {more, binary(), Req} when Req::req().
|
|
|
+read_body(Req=#{has_body := false}, _) ->
|
|
|
+ {ok, <<>>, Req};
|
|
|
+read_body(Req=#{has_read_body := true}, _) ->
|
|
|
+ {ok, <<>>, Req};
|
|
|
read_body(Req=#{pid := Pid, streamid := StreamID}, Opts) ->
|
|
|
- %% @todo Opts should be a map
|
|
|
- Length = case lists:keyfind(length, 1, Opts) of
|
|
|
- false -> 8000000;
|
|
|
- {_, ChunkLen0} -> ChunkLen0
|
|
|
- end,
|
|
|
- ReadTimeout = case lists:keyfind(read_timeout, 1, Opts) of
|
|
|
- false -> 15000;
|
|
|
- {_, ReadTimeout0} -> ReadTimeout0
|
|
|
- end,
|
|
|
+ Length = maps:get(length, Opts, 8000000),
|
|
|
+ Period = maps:get(period, Opts, 15000),
|
|
|
+ Timeout = maps:get(timeout, Opts, Period + 1000),
|
|
|
Ref = make_ref(),
|
|
|
- Pid ! {{Pid, StreamID}, {read_body, Ref, Length}},
|
|
|
+ Pid ! {{Pid, StreamID}, {read_body, Ref, Length, Period}},
|
|
|
receive
|
|
|
{request_body, Ref, nofin, Body} ->
|
|
|
{more, Body, Req};
|
|
|
{request_body, Ref, {fin, BodyLength}, Body} ->
|
|
|
{ok, Body, set_body_length(Req, BodyLength)}
|
|
|
- after ReadTimeout ->
|
|
|
- exit(read_body_timeout)
|
|
|
+ after Timeout ->
|
|
|
+ exit(timeout)
|
|
|
end.
|
|
|
|
|
|
set_body_length(Req=#{headers := Headers}, BodyLength) ->
|
|
|
Req#{
|
|
|
headers => Headers#{<<"content-length">> => integer_to_binary(BodyLength)},
|
|
|
- body_length => BodyLength
|
|
|
+ body_length => BodyLength,
|
|
|
+ has_read_body => true
|
|
|
}.
|
|
|
|
|
|
--spec body(Req, body_opts()) -> {ok, binary(), Req} | {more, binary(), Req} when Req::req().
|
|
|
-body(Req=#http_req{body_state=waiting}, Opts) ->
|
|
|
- %% Send a 100 continue if needed (enabled by default).
|
|
|
- case lists:keyfind(continue, 1, Opts) of
|
|
|
- {_, false} ->
|
|
|
- ok;
|
|
|
- _ ->
|
|
|
- ExpectHeader = parse_header(<<"expect">>, Req),
|
|
|
- ok = case ExpectHeader of
|
|
|
- continue -> continue(Req);
|
|
|
- _ -> ok
|
|
|
- end
|
|
|
- end,
|
|
|
- %% Initialize body streaming state.
|
|
|
- CFun = case lists:keyfind(content_decode, 1, Opts) of
|
|
|
- false ->
|
|
|
- fun body_content_decode_identity/1;
|
|
|
- {_, CFun0} ->
|
|
|
- CFun0
|
|
|
- end,
|
|
|
- case lists:keyfind(transfer_decode, 1, Opts) of
|
|
|
- false ->
|
|
|
- case parse_header(<<"transfer-encoding">>, Req) of
|
|
|
- [<<"chunked">>] ->
|
|
|
- body(Req#http_req{body_state={stream, 0,
|
|
|
- fun cow_http_te:stream_chunked/2, {0, 0}, CFun}}, Opts);
|
|
|
- [<<"identity">>] ->
|
|
|
- case body_length(Req) of
|
|
|
- 0 ->
|
|
|
- {ok, <<>>, Req#http_req{body_state=done}};
|
|
|
- Len ->
|
|
|
- body(Req#http_req{body_state={stream, Len,
|
|
|
- fun cow_http_te:stream_identity/2, {0, Len},
|
|
|
- CFun}}, Opts)
|
|
|
- end
|
|
|
- end;
|
|
|
- {_, TFun, TState} ->
|
|
|
- body(Req#http_req{body_state={stream, 0,
|
|
|
- TFun, TState, CFun}}, Opts)
|
|
|
- end;
|
|
|
-body(Req=#http_req{body_state=done}, _) ->
|
|
|
- {ok, <<>>, Req};
|
|
|
-body(Req, Opts) ->
|
|
|
- ChunkLen = case lists:keyfind(length, 1, Opts) of
|
|
|
- false -> 8000000;
|
|
|
- {_, ChunkLen0} -> ChunkLen0
|
|
|
- end,
|
|
|
- ReadLen = case lists:keyfind(read_length, 1, Opts) of
|
|
|
- false -> 1000000;
|
|
|
- {_, ReadLen0} -> ReadLen0
|
|
|
- end,
|
|
|
- ReadTimeout = case lists:keyfind(read_timeout, 1, Opts) of
|
|
|
- false -> 15000;
|
|
|
- {_, ReadTimeout0} -> ReadTimeout0
|
|
|
- end,
|
|
|
- body_loop(Req, ReadTimeout, ReadLen, ChunkLen, <<>>).
|
|
|
-
|
|
|
-%% Default identity function for content decoding.
|
|
|
-%% @todo Move into cowlib when more content decode functions get implemented.
|
|
|
-body_content_decode_identity(Data) -> Data.
|
|
|
+-spec read_urlencoded_body(Req) -> {ok, [{binary(), binary() | true}], Req} when Req::req().
|
|
|
+read_urlencoded_body(Req) ->
|
|
|
+ read_urlencoded_body(Req, #{length => 64000, period => 5000}).
|
|
|
|
|
|
-body_loop(Req=#http_req{buffer=Buffer, body_state={stream, Length, _, _, _}},
|
|
|
- ReadTimeout, ReadLength, ChunkLength, Acc) ->
|
|
|
- {Tag, Res, Req2} = case Buffer of
|
|
|
- <<>> ->
|
|
|
- body_recv(Req, ReadTimeout, min(Length, ReadLength));
|
|
|
- _ ->
|
|
|
- body_decode(Req, ReadTimeout)
|
|
|
- end,
|
|
|
- case {Tag, Res} of
|
|
|
- {ok, Data} ->
|
|
|
- {ok, << Acc/binary, Data/binary >>, Req2};
|
|
|
- {more, Data} ->
|
|
|
- Acc2 = << Acc/binary, Data/binary >>,
|
|
|
- case byte_size(Acc2) >= ChunkLength of
|
|
|
- true -> {more, Acc2, Req2};
|
|
|
- false -> body_loop(Req2, ReadTimeout, ReadLength, ChunkLength, Acc2)
|
|
|
- end
|
|
|
- end.
|
|
|
+-spec read_urlencoded_body(Req, body_opts()) -> {ok, [{binary(), binary() | true}], Req} when Req::req().
|
|
|
+read_urlencoded_body(Req0, Opts) ->
|
|
|
+ {ok, Body, Req} = read_body(Req0, Opts),
|
|
|
+ {ok, cow_qs:parse_qs(Body), Req}.
|
|
|
|
|
|
-body_recv(Req=#http_req{transport=Transport, socket=Socket, buffer=Buffer},
|
|
|
- ReadTimeout, ReadLength) ->
|
|
|
- {ok, Data} = Transport:recv(Socket, ReadLength, ReadTimeout),
|
|
|
- body_decode(Req#http_req{buffer= << Buffer/binary, Data/binary >>}, ReadTimeout).
|
|
|
-
|
|
|
-%% Two decodings happen. First a decoding function is applied to the
|
|
|
-%% transferred data, and then another is applied to the actual content.
|
|
|
-%%
|
|
|
-%% Transfer encoding is generally used for chunked bodies. The decoding
|
|
|
-%% function uses a state to keep track of how much it has read, which is
|
|
|
-%% also initialized through this function.
|
|
|
-%%
|
|
|
-%% Content encoding is generally used for compression.
|
|
|
-%%
|
|
|
-%% @todo Handle chunked after-the-facts headers.
|
|
|
-%% @todo Depending on the length returned we might want to 0 or +5 it.
|
|
|
-body_decode(Req=#http_req{buffer=Data, body_state={stream, _,
|
|
|
- TDecode, TState, CDecode}}, ReadTimeout) ->
|
|
|
- case TDecode(Data, TState) of
|
|
|
- more ->
|
|
|
- body_recv(Req#http_req{body_state={stream, 0,
|
|
|
- TDecode, TState, CDecode}}, ReadTimeout, 0);
|
|
|
- {more, Data2, TState2} ->
|
|
|
- {more, CDecode(Data2), Req#http_req{body_state={stream, 0,
|
|
|
- TDecode, TState2, CDecode}, buffer= <<>>}};
|
|
|
- {more, Data2, Length, TState2} when is_integer(Length) ->
|
|
|
- {more, CDecode(Data2), Req#http_req{body_state={stream, Length,
|
|
|
- TDecode, TState2, CDecode}, buffer= <<>>}};
|
|
|
- {more, Data2, Rest, TState2} ->
|
|
|
- {more, CDecode(Data2), Req#http_req{body_state={stream, 0,
|
|
|
- TDecode, TState2, CDecode}, buffer=Rest}};
|
|
|
- {done, TotalLength, Rest} ->
|
|
|
- {ok, <<>>, body_decode_end(Req, TotalLength, Rest)};
|
|
|
- {done, Data2, TotalLength, Rest} ->
|
|
|
- {ok, CDecode(Data2), body_decode_end(Req, TotalLength, Rest)}
|
|
|
- end.
|
|
|
-
|
|
|
-body_decode_end(Req=#http_req{headers=Headers}, TotalLength, Rest) ->
|
|
|
- Headers2 = lists:keystore(<<"content-length">>, 1, Headers,
|
|
|
- {<<"content-length">>, integer_to_binary(TotalLength)}),
|
|
|
- %% At this point we just assume TEs were all decoded.
|
|
|
- Headers3 = lists:keydelete(<<"transfer-encoding">>, 1, Headers2),
|
|
|
- Req#http_req{buffer=Rest, body_state=done, headers=Headers3}.
|
|
|
-
|
|
|
--spec body_qs(Req) -> {ok, [{binary(), binary() | true}], Req}
|
|
|
- | {badlength, Req} when Req::req().
|
|
|
-body_qs(Req) ->
|
|
|
- body_qs(Req, [
|
|
|
- {length, 64000},
|
|
|
- {read_length, 64000},
|
|
|
- {read_timeout, 5000}]).
|
|
|
-
|
|
|
--spec body_qs(Req, body_opts()) -> {ok, [{binary(), binary() | true}], Req}
|
|
|
- | {badlength, Req} when Req::req().
|
|
|
-body_qs(Req, Opts) ->
|
|
|
- case read_body(Req, Opts) of
|
|
|
- {ok, Body, Req2} ->
|
|
|
- {ok, cow_qs:parse_qs(Body), Req2};
|
|
|
- {more, _, Req2} ->
|
|
|
- {badlength, Req2}
|
|
|
- end.
|
|
|
-
|
|
|
-%% Multipart API.
|
|
|
+%% Multipart.
|
|
|
|
|
|
-spec part(Req)
|
|
|
-> {ok, cow_multipart:headers(), Req} | {done, Req}
|
|
|
when Req::req().
|
|
|
part(Req) ->
|
|
|
- part(Req, [
|
|
|
- {length, 64000},
|
|
|
- {read_length, 64000},
|
|
|
- {read_timeout, 5000}]).
|
|
|
+ part(Req, #{length => 64000, period => 5000}).
|
|
|
|
|
|
-spec part(Req, body_opts())
|
|
|
-> {ok, cow_multipart:headers(), Req} | {done, Req}
|
|
@@ -635,7 +469,7 @@ part(Buffer, Opts, Req=#{multipart := {Boundary, _}}) ->
|
|
|
-> {ok, binary(), Req} | {more, binary(), Req}
|
|
|
when Req::req().
|
|
|
part_body(Req) ->
|
|
|
- part_body(Req, []).
|
|
|
+ part_body(Req, #{}).
|
|
|
|
|
|
-spec part_body(Req, body_opts())
|
|
|
-> {ok, binary(), Req} | {more, binary(), Req}
|
|
@@ -649,11 +483,8 @@ part_body(Req, Opts) ->
|
|
|
end.
|
|
|
|
|
|
part_body(Buffer, Opts, Req=#{multipart := {Boundary, _}}, Acc) ->
|
|
|
- ChunkLen = case lists:keyfind(length, 1, Opts) of
|
|
|
- false -> 8000000;
|
|
|
- {_, ChunkLen0} -> ChunkLen0
|
|
|
- end,
|
|
|
- case byte_size(Acc) > ChunkLen of
|
|
|
+ Length = maps:get(length, Opts, 8000000),
|
|
|
+ case byte_size(Acc) > Length of
|
|
|
true ->
|
|
|
{more, Acc, Req#{multipart => {Boundary, Buffer}}};
|
|
|
false ->
|
|
@@ -686,7 +517,12 @@ stream_multipart(Req=#{multipart := {_, <<>>}}, Opts) ->
|
|
|
stream_multipart(Req=#{multipart := {Boundary, Buffer}}, _) ->
|
|
|
{Buffer, Req#{multipart => {Boundary, <<>>}}}.
|
|
|
|
|
|
-%% Response API.
|
|
|
+%% Response.
|
|
|
+
|
|
|
+-spec set_resp_cookie(iodata(), iodata(), Req)
|
|
|
+ -> Req when Req::req().
|
|
|
+set_resp_cookie(Name, Value, Req) ->
|
|
|
+ set_resp_cookie(Name, Value, #{}, Req).
|
|
|
|
|
|
%% The cookie name cannot contain any of the following characters:
|
|
|
%% =,;\s\t\r\n\013\014
|
|
@@ -696,9 +532,11 @@ stream_multipart(Req=#{multipart := {Boundary, Buffer}}, _) ->
|
|
|
-spec set_resp_cookie(iodata(), iodata(), cookie_opts(), Req)
|
|
|
-> Req when Req::req().
|
|
|
set_resp_cookie(Name, Value, Opts, Req) ->
|
|
|
- Cookie = cow_cookie:setcookie(Name, Value, Opts),
|
|
|
- %% @todo Nah, keep separate.
|
|
|
- set_resp_header(<<"set-cookie">>, Cookie, Req).
|
|
|
+ Cookie = cow_cookie:setcookie(Name, Value, maps:to_list(Opts)),
|
|
|
+ RespCookies = maps:get(resp_cookies, Req, #{}),
|
|
|
+ Req#{resp_cookies => RespCookies#{Name => Cookie}}.
|
|
|
+
|
|
|
+%% @todo We could add has_resp_cookie and delete_resp_cookie now.
|
|
|
|
|
|
-spec set_resp_header(binary(), iodata(), Req)
|
|
|
-> Req when Req::req().
|
|
@@ -707,29 +545,9 @@ set_resp_header(Name, Value, Req=#{resp_headers := RespHeaders}) ->
|
|
|
set_resp_header(Name,Value, Req) ->
|
|
|
Req#{resp_headers => #{Name => Value}}.
|
|
|
|
|
|
-%% @todo {sendfile, Offset, Bytes, Path} tuple
|
|
|
--spec set_resp_body(iodata(), Req) -> Req when Req::req().
|
|
|
+-spec set_resp_body(resp_body(), Req) -> Req when Req::req().
|
|
|
set_resp_body(Body, Req) ->
|
|
|
Req#{resp_body => Body}.
|
|
|
-%set_resp_body(Body, Req) ->
|
|
|
-% Req#http_req{resp_body=Body}.
|
|
|
-
|
|
|
--spec set_resp_body_fun(resp_body_fun(), Req) -> Req when Req::req().
|
|
|
-set_resp_body_fun(StreamFun, Req) when is_function(StreamFun) ->
|
|
|
- Req#http_req{resp_body=StreamFun}.
|
|
|
-
|
|
|
-%% If the body function crashes while writing the response body or writes
|
|
|
-%% fewer bytes than declared the behaviour is undefined.
|
|
|
--spec set_resp_body_fun(non_neg_integer(), resp_body_fun(), Req)
|
|
|
- -> Req when Req::req();
|
|
|
- (chunked, resp_chunked_fun(), Req)
|
|
|
- -> Req when Req::req().
|
|
|
-set_resp_body_fun(StreamLen, StreamFun, Req)
|
|
|
- when is_integer(StreamLen), is_function(StreamFun) ->
|
|
|
- Req#http_req{resp_body={StreamLen, StreamFun}};
|
|
|
-set_resp_body_fun(chunked, StreamFun, Req)
|
|
|
- when is_function(StreamFun) ->
|
|
|
- Req#http_req{resp_body={chunked, StreamFun}}.
|
|
|
|
|
|
-spec has_resp_header(binary(), req()) -> boolean().
|
|
|
has_resp_header(Name, #{resp_headers := RespHeaders}) ->
|
|
@@ -738,22 +556,13 @@ has_resp_header(_, _) ->
|
|
|
false.
|
|
|
|
|
|
-spec has_resp_body(req()) -> boolean().
|
|
|
-has_resp_body(#{resp_body := {sendfile, Len, _}}) ->
|
|
|
- Len > 0;
|
|
|
+has_resp_body(#{resp_body := {sendfile, _, _, _}}) ->
|
|
|
+ true;
|
|
|
has_resp_body(#{resp_body := RespBody}) ->
|
|
|
iolist_size(RespBody) > 0;
|
|
|
has_resp_body(_) ->
|
|
|
false.
|
|
|
|
|
|
-%has_resp_body(#http_req{resp_body=RespBody}) when is_function(RespBody) ->
|
|
|
-% true;
|
|
|
-%has_resp_body(#http_req{resp_body={chunked, _}}) ->
|
|
|
-% true;
|
|
|
-%has_resp_body(#http_req{resp_body={Length, _}}) ->
|
|
|
-% Length > 0;
|
|
|
-%has_resp_body(#http_req{resp_body=RespBody}) ->
|
|
|
-% iolist_size(RespBody) > 0.
|
|
|
-
|
|
|
-spec delete_resp_header(binary(), Req)
|
|
|
-> Req when Req::req().
|
|
|
delete_resp_header(Name, Req=#{resp_headers := RespHeaders}) ->
|
|
@@ -770,287 +579,89 @@ reply(Status, Headers, Req=#{resp_body := Body}) ->
|
|
|
reply(Status, Headers, Req) ->
|
|
|
reply(Status, Headers, <<>>, Req).
|
|
|
|
|
|
--spec reply(cowboy:http_status(), cowboy:http_headers(),
|
|
|
- iodata() | resp_body_fun() | {non_neg_integer(), resp_body_fun()}
|
|
|
- | {chunked, resp_chunked_fun()}, Req)
|
|
|
+-spec reply(cowboy:http_status(), cowboy:http_headers(), resp_body(), Req)
|
|
|
-> Req when Req::req().
|
|
|
-reply(Status, Headers, Stream = {stream, undefined, _}, Req) ->
|
|
|
- do_stream_reply(Status, Headers, Stream, Req);
|
|
|
-reply(Status, Headers, Stream = {stream, Len, _}, Req) ->
|
|
|
- do_stream_reply(Status, Headers#{
|
|
|
- <<"content-length">> => integer_to_binary(Len)
|
|
|
- }, Stream, Req);
|
|
|
-reply(Status, Headers, SendFile = {sendfile, _, Len, _}, Req) ->
|
|
|
+reply(Status, Headers, SendFile = {sendfile, _, Len, _}, Req)
|
|
|
+ when is_integer(Status); is_binary(Status) ->
|
|
|
do_reply(Status, Headers#{
|
|
|
<<"content-length">> => integer_to_binary(Len)
|
|
|
}, SendFile, Req);
|
|
|
-reply(Status, Headers, Body, Req) ->
|
|
|
+reply(Status, Headers, Body, Req)
|
|
|
+ when is_integer(Status); is_binary(Status) ->
|
|
|
do_reply(Status, Headers#{
|
|
|
<<"content-length">> => integer_to_binary(iolist_size(Body))
|
|
|
}, Body, Req).
|
|
|
|
|
|
-do_stream_reply(Status, Headers, {stream, _, Fun}, Req=#{pid := Pid, streamid := StreamID}) ->
|
|
|
- Pid ! {{Pid, StreamID}, {headers, Status, response_headers(Headers, Req)}},
|
|
|
- Fun(),
|
|
|
- ok.
|
|
|
-
|
|
|
+%% Don't send any body for HEAD responses. While the protocol code is
|
|
|
+%% supposed to enforce this rule, we prefer to avoid copying too much
|
|
|
+%% data around if we can avoid it.
|
|
|
+do_reply(Status, Headers, _, Req=#{pid := Pid, streamid := StreamID, method := <<"HEAD">>}) ->
|
|
|
+ Pid ! {{Pid, StreamID}, {response, Status, response_headers(Headers, Req), <<>>}},
|
|
|
+ ok;
|
|
|
do_reply(Status, Headers, Body, Req=#{pid := Pid, streamid := StreamID}) ->
|
|
|
Pid ! {{Pid, StreamID}, {response, Status, response_headers(Headers, Req), Body}},
|
|
|
ok.
|
|
|
|
|
|
--spec send_body(iodata(), fin | nofin, req()) -> ok.
|
|
|
-send_body(Data, IsFin, #{pid := Pid, streamid := StreamID}) ->
|
|
|
- Pid ! {{Pid, StreamID}, {data, IsFin, Data}},
|
|
|
- ok.
|
|
|
+-spec stream_reply(cowboy:http_status(), Req) -> Req when Req::req().
|
|
|
+stream_reply(Status, Req) ->
|
|
|
+ stream_reply(Status, #{}, Req).
|
|
|
|
|
|
-response_headers(Headers, Req) ->
|
|
|
- RespHeaders = maps:get(resp_headers, Req, #{}),
|
|
|
- maps:merge(#{
|
|
|
- <<"date">> => cowboy_clock:rfc1123(),
|
|
|
- <<"server">> => <<"Cowboy">>
|
|
|
- }, maps:merge(RespHeaders, Headers)).
|
|
|
-
|
|
|
-%reply(Status, Headers, Body, Req=#http_req{
|
|
|
-% socket=Socket, transport=Transport,
|
|
|
-% version=Version, connection=Connection,
|
|
|
-% method=Method, resp_compress=Compress,
|
|
|
-% resp_state=RespState, resp_headers=RespHeaders})
|
|
|
-% when RespState =:= waiting; RespState =:= waiting_stream ->
|
|
|
-% Req3 = case Body of
|
|
|
-% BodyFun when is_function(BodyFun) ->
|
|
|
-% %% We stream the response body until we close the connection.
|
|
|
-% RespConn = close,
|
|
|
-% {RespType, Req2} = if
|
|
|
-% true ->
|
|
|
-% response(Status, Headers, RespHeaders, [
|
|
|
-% {<<"connection">>, <<"close">>},
|
|
|
-% {<<"date">>, cowboy_clock:rfc1123()},
|
|
|
-% {<<"server">>, <<"Cowboy">>},
|
|
|
-% {<<"transfer-encoding">>, <<"identity">>}
|
|
|
-% ], <<>>, Req)
|
|
|
-% end,
|
|
|
-% if RespType =/= hook, Method =/= <<"HEAD">> ->
|
|
|
-% BodyFun(Socket, Transport);
|
|
|
-% true -> ok
|
|
|
-% end,
|
|
|
-% Req2#http_req{connection=RespConn};
|
|
|
-% {chunked, BodyFun} ->
|
|
|
-% %% We stream the response body in chunks.
|
|
|
-% {RespType, Req2} = chunked_response(Status, Headers, Req),
|
|
|
-% if RespType =/= hook, Method =/= <<"HEAD">> ->
|
|
|
-% ChunkFun = fun(IoData) -> chunk(IoData, Req2) end,
|
|
|
-% BodyFun(ChunkFun),
|
|
|
-% %% Send the last chunk if chunked encoding was used.
|
|
|
-% if
|
|
|
-% Version =:= 'HTTP/1.0'; RespState =:= waiting_stream ->
|
|
|
-% Req2;
|
|
|
-% true ->
|
|
|
-% last_chunk(Req2)
|
|
|
-% end;
|
|
|
-% true -> Req2
|
|
|
-% end;
|
|
|
-% {ContentLength, BodyFun} ->
|
|
|
-% %% We stream the response body for ContentLength bytes.
|
|
|
-% RespConn = response_connection(Headers, Connection),
|
|
|
-% {RespType, Req2} = response(Status, Headers, RespHeaders, [
|
|
|
-% {<<"content-length">>, integer_to_list(ContentLength)},
|
|
|
-% {<<"date">>, cowboy_clock:rfc1123()},
|
|
|
-% {<<"server">>, <<"Cowboy">>}
|
|
|
-% |HTTP11Headers], stream, Req),
|
|
|
-% if RespType =/= hook, Method =/= <<"HEAD">> ->
|
|
|
-% BodyFun(Socket, Transport);
|
|
|
-% true -> ok
|
|
|
-% end,
|
|
|
-% Req2#http_req{connection=RespConn};
|
|
|
-% _ when Compress ->
|
|
|
-% RespConn = response_connection(Headers, Connection),
|
|
|
-% Req2 = reply_may_compress(Status, Headers, Body, Req,
|
|
|
-% RespHeaders, HTTP11Headers, Method),
|
|
|
-% Req2#http_req{connection=RespConn};
|
|
|
-% _ ->
|
|
|
-% RespConn = response_connection(Headers, Connection),
|
|
|
-% Req2 = reply_no_compress(Status, Headers, Body, Req,
|
|
|
-% RespHeaders, HTTP11Headers, Method, iolist_size(Body)),
|
|
|
-% Req2#http_req{connection=RespConn}
|
|
|
-% end,
|
|
|
-% Req3#http_req{resp_state=done, resp_headers=[], resp_body= <<>>}.
|
|
|
-
|
|
|
-%reply_may_compress(Status, Headers, Body, Req,
|
|
|
-% RespHeaders, HTTP11Headers, Method) ->
|
|
|
-% BodySize = iolist_size(Body),
|
|
|
-% try parse_header(<<"accept-encoding">>, Req) of
|
|
|
-% Encodings ->
|
|
|
-% CanGzip = (BodySize > 300)
|
|
|
-% andalso (false =:= lists:keyfind(<<"content-encoding">>,
|
|
|
-% 1, Headers))
|
|
|
-% andalso (false =:= lists:keyfind(<<"content-encoding">>,
|
|
|
-% 1, RespHeaders))
|
|
|
-% andalso (false =:= lists:keyfind(<<"transfer-encoding">>,
|
|
|
-% 1, Headers))
|
|
|
-% andalso (false =:= lists:keyfind(<<"transfer-encoding">>,
|
|
|
-% 1, RespHeaders))
|
|
|
-% andalso (Encodings =/= undefined)
|
|
|
-% andalso (false =/= lists:keyfind(<<"gzip">>, 1, Encodings)),
|
|
|
-% case CanGzip of
|
|
|
-% true ->
|
|
|
-% GzBody = zlib:gzip(Body),
|
|
|
-% {_, Req2} = response(Status, Headers, RespHeaders, [
|
|
|
-% {<<"content-length">>, integer_to_list(byte_size(GzBody))},
|
|
|
-% {<<"content-encoding">>, <<"gzip">>},
|
|
|
-% |HTTP11Headers],
|
|
|
-% case Method of <<"HEAD">> -> <<>>; _ -> GzBody end,
|
|
|
-% Req),
|
|
|
-% Req2;
|
|
|
-% false ->
|
|
|
-% reply_no_compress(Status, Headers, Body, Req,
|
|
|
-% RespHeaders, HTTP11Headers, Method, BodySize)
|
|
|
-% end
|
|
|
-% catch _:_ ->
|
|
|
-% reply_no_compress(Status, Headers, Body, Req,
|
|
|
-% RespHeaders, HTTP11Headers, Method, BodySize)
|
|
|
-% end.
|
|
|
-%
|
|
|
-%reply_no_compress(Status, Headers, Body, Req,
|
|
|
-% RespHeaders, HTTP11Headers, Method, BodySize) ->
|
|
|
-% {_, Req2} = response(Status, Headers, RespHeaders, [
|
|
|
-% {<<"content-length">>, integer_to_list(BodySize)},
|
|
|
-% |HTTP11Headers],
|
|
|
-% case Method of <<"HEAD">> -> <<>>; _ -> Body end,
|
|
|
-% Req),
|
|
|
-% Req2.
|
|
|
-
|
|
|
--spec chunked_reply(cowboy:http_status(), Req) -> Req when Req::req().
|
|
|
-chunked_reply(Status, Req) ->
|
|
|
- chunked_reply(Status, #{}, Req).
|
|
|
-
|
|
|
--spec chunked_reply(cowboy:http_status(), cowboy:http_headers(), Req)
|
|
|
+-spec stream_reply(cowboy:http_status(), cowboy:http_headers(), Req)
|
|
|
-> Req when Req::req().
|
|
|
-chunked_reply(Status, Headers, Req=#{pid := Pid, streamid := StreamID}) ->
|
|
|
+stream_reply(Status, Headers=#{}, Req=#{pid := Pid, streamid := StreamID})
|
|
|
+ when is_integer(Status); is_binary(Status) ->
|
|
|
Pid ! {{Pid, StreamID}, {headers, Status, response_headers(Headers, Req)}},
|
|
|
- Req. %% @todo return ok
|
|
|
-% ok.
|
|
|
+ ok.
|
|
|
|
|
|
--spec chunk(iodata(), req()) -> ok.
|
|
|
-chunk(_Data, #{method := <<"HEAD">>}) ->
|
|
|
+-spec stream_body(iodata(), fin | nofin, req()) -> ok.
|
|
|
+%% Don't send any body for HEAD responses.
|
|
|
+stream_body(_, _, #{method := <<"HEAD">>}) ->
|
|
|
ok;
|
|
|
-chunk(Data, #{pid := Pid, streamid := StreamID}) ->
|
|
|
+%% Don't send a message if the data is empty, except for the
|
|
|
+%% very last message with IsFin=fin.
|
|
|
+stream_body(Data, IsFin=nofin, #{pid := Pid, streamid := StreamID}) ->
|
|
|
case iolist_size(Data) of
|
|
|
0 -> ok;
|
|
|
_ ->
|
|
|
- Pid ! {{Pid, StreamID}, {data, nofin, Data}},
|
|
|
+ Pid ! {{Pid, StreamID}, {data, IsFin, Data}},
|
|
|
ok
|
|
|
- end.
|
|
|
-
|
|
|
-%% If ever made public, need to send nothing if HEAD.
|
|
|
--spec last_chunk(Req) -> Req when Req::req().
|
|
|
-last_chunk(Req=#http_req{socket=Socket, transport=Transport}) ->
|
|
|
- _ = Transport:send(Socket, <<"0\r\n\r\n">>),
|
|
|
- Req#http_req{resp_state=done}.
|
|
|
-
|
|
|
--spec continue(req()) -> ok.
|
|
|
-continue(#http_req{socket=Socket, transport=Transport,
|
|
|
- version=Version}) ->
|
|
|
- HTTPVer = atom_to_binary(Version, latin1),
|
|
|
- ok = Transport:send(Socket,
|
|
|
- << HTTPVer/binary, " ", (status(100))/binary, "\r\n\r\n" >>).
|
|
|
-
|
|
|
-%% Meant to be used internally for sending errors after crashes.
|
|
|
--spec maybe_reply([{module(), atom(), arity() | [term()], _}], req()) -> ok.
|
|
|
-maybe_reply(Stacktrace, Req) ->
|
|
|
- receive
|
|
|
- {cowboy_req, resp_sent} -> ok
|
|
|
- after 0 ->
|
|
|
- _ = do_maybe_reply(Stacktrace, Req),
|
|
|
- ok
|
|
|
- end.
|
|
|
+ end;
|
|
|
+stream_body(Data, IsFin, #{pid := Pid, streamid := StreamID}) ->
|
|
|
+ Pid ! {{Pid, StreamID}, {data, IsFin, Data}},
|
|
|
+ ok.
|
|
|
|
|
|
-do_maybe_reply([{erlang, binary_to_integer, _, _}, {cow_http_hd, parse_content_length, _, _}|_], Req) ->
|
|
|
- cowboy_req:reply(400, Req);
|
|
|
-do_maybe_reply([{cow_http_hd, _, _, _}|_], Req) ->
|
|
|
- cowboy_req:reply(400, Req);
|
|
|
-do_maybe_reply(_, Req) ->
|
|
|
- cowboy_req:reply(500, Req).
|
|
|
+push(Path, Headers, Req) ->
|
|
|
+ push(Path, Headers, Req, #{}).
|
|
|
|
|
|
--spec ensure_response(req(), cowboy:http_status()) -> ok.
|
|
|
-%% The response has already been fully sent to the client.
|
|
|
-ensure_response(#http_req{resp_state=done}, _) ->
|
|
|
- ok;
|
|
|
-%% No response has been sent but everything apparently went fine.
|
|
|
-%% Reply with the status code found in the second argument.
|
|
|
-ensure_response(Req=#http_req{resp_state=RespState}, Status)
|
|
|
- when RespState =:= waiting; RespState =:= waiting_stream ->
|
|
|
- _ = reply(Status, [], [], Req),
|
|
|
- ok;
|
|
|
-%% Terminate the chunked body for HTTP/1.1 only.
|
|
|
-ensure_response(#http_req{method= <<"HEAD">>}, _) ->
|
|
|
- ok;
|
|
|
-ensure_response(Req=#http_req{resp_state=chunks}, _) ->
|
|
|
- _ = last_chunk(Req),
|
|
|
- ok;
|
|
|
-ensure_response(#http_req{}, _) ->
|
|
|
+%% @todo Optimization: don't send anything at all for HTTP/1.0 and HTTP/1.1.
|
|
|
+%% @todo Path, Headers, Opts, everything should be in proper binary,
|
|
|
+%% or normalized when creating the Req object.
|
|
|
+push(Path, Headers, #{pid := Pid, streamid := StreamID,
|
|
|
+ scheme := Scheme0, host := Host0, port := Port0}, Opts) ->
|
|
|
+ Method = maps:get(method, Opts, <<"GET">>),
|
|
|
+ Scheme = maps:get(scheme, Opts, Scheme0),
|
|
|
+ Host = maps:get(host, Opts, Host0),
|
|
|
+ Port = maps:get(port, Opts, Port0),
|
|
|
+ Qs = maps:get(qs, Opts, <<>>),
|
|
|
+ Pid ! {{Pid, StreamID}, {push, Method, Scheme, Host, Port, Path, Qs, Headers}},
|
|
|
ok.
|
|
|
|
|
|
%% Internal.
|
|
|
|
|
|
--spec status(cowboy:http_status()) -> binary().
|
|
|
-status(100) -> <<"100 Continue">>;
|
|
|
-status(101) -> <<"101 Switching Protocols">>;
|
|
|
-status(102) -> <<"102 Processing">>;
|
|
|
-status(200) -> <<"200 OK">>;
|
|
|
-status(201) -> <<"201 Created">>;
|
|
|
-status(202) -> <<"202 Accepted">>;
|
|
|
-status(203) -> <<"203 Non-Authoritative Information">>;
|
|
|
-status(204) -> <<"204 No Content">>;
|
|
|
-status(205) -> <<"205 Reset Content">>;
|
|
|
-status(206) -> <<"206 Partial Content">>;
|
|
|
-status(207) -> <<"207 Multi-Status">>;
|
|
|
-status(226) -> <<"226 IM Used">>;
|
|
|
-status(300) -> <<"300 Multiple Choices">>;
|
|
|
-status(301) -> <<"301 Moved Permanently">>;
|
|
|
-status(302) -> <<"302 Found">>;
|
|
|
-status(303) -> <<"303 See Other">>;
|
|
|
-status(304) -> <<"304 Not Modified">>;
|
|
|
-status(305) -> <<"305 Use Proxy">>;
|
|
|
-status(306) -> <<"306 Switch Proxy">>;
|
|
|
-status(307) -> <<"307 Temporary Redirect">>;
|
|
|
-status(400) -> <<"400 Bad Request">>;
|
|
|
-status(401) -> <<"401 Unauthorized">>;
|
|
|
-status(402) -> <<"402 Payment Required">>;
|
|
|
-status(403) -> <<"403 Forbidden">>;
|
|
|
-status(404) -> <<"404 Not Found">>;
|
|
|
-status(405) -> <<"405 Method Not Allowed">>;
|
|
|
-status(406) -> <<"406 Not Acceptable">>;
|
|
|
-status(407) -> <<"407 Proxy Authentication Required">>;
|
|
|
-status(408) -> <<"408 Request Timeout">>;
|
|
|
-status(409) -> <<"409 Conflict">>;
|
|
|
-status(410) -> <<"410 Gone">>;
|
|
|
-status(411) -> <<"411 Length Required">>;
|
|
|
-status(412) -> <<"412 Precondition Failed">>;
|
|
|
-status(413) -> <<"413 Request Entity Too Large">>;
|
|
|
-status(414) -> <<"414 Request-URI Too Long">>;
|
|
|
-status(415) -> <<"415 Unsupported Media Type">>;
|
|
|
-status(416) -> <<"416 Requested Range Not Satisfiable">>;
|
|
|
-status(417) -> <<"417 Expectation Failed">>;
|
|
|
-status(418) -> <<"418 I'm a teapot">>;
|
|
|
-status(422) -> <<"422 Unprocessable Entity">>;
|
|
|
-status(423) -> <<"423 Locked">>;
|
|
|
-status(424) -> <<"424 Failed Dependency">>;
|
|
|
-status(425) -> <<"425 Unordered Collection">>;
|
|
|
-status(426) -> <<"426 Upgrade Required">>;
|
|
|
-status(428) -> <<"428 Precondition Required">>;
|
|
|
-status(429) -> <<"429 Too Many Requests">>;
|
|
|
-status(431) -> <<"431 Request Header Fields Too Large">>;
|
|
|
-status(500) -> <<"500 Internal Server Error">>;
|
|
|
-status(501) -> <<"501 Not Implemented">>;
|
|
|
-status(502) -> <<"502 Bad Gateway">>;
|
|
|
-status(503) -> <<"503 Service Unavailable">>;
|
|
|
-status(504) -> <<"504 Gateway Timeout">>;
|
|
|
-status(505) -> <<"505 HTTP Version Not Supported">>;
|
|
|
-status(506) -> <<"506 Variant Also Negotiates">>;
|
|
|
-status(507) -> <<"507 Insufficient Storage">>;
|
|
|
-status(510) -> <<"510 Not Extended">>;
|
|
|
-status(511) -> <<"511 Network Authentication Required">>;
|
|
|
-status(B) when is_binary(B) -> B.
|
|
|
+%% @todo What about set-cookie headers set through set_resp_header or reply?
|
|
|
+response_headers(Headers0, Req) ->
|
|
|
+ RespHeaders = maps:get(resp_headers, Req, #{}),
|
|
|
+ Headers = maps:merge(#{
|
|
|
+ <<"date">> => cowboy_clock:rfc1123(),
|
|
|
+ <<"server">> => <<"Cowboy">>
|
|
|
+ }, maps:merge(RespHeaders, Headers0)),
|
|
|
+ %% The set-cookie header is special; we can only send one cookie per header.
|
|
|
+ %% We send the list of values for many cookies in one key of the map,
|
|
|
+ %% and let the protocols deal with it directly.
|
|
|
+ case maps:get(resp_cookies, Req, undefined) of
|
|
|
+ undefined -> Headers;
|
|
|
+ RespCookies -> Headers#{<<"set-cookie">> => maps:values(RespCookies)}
|
|
|
+ end.
|
|
|
|
|
|
%% Create map, convert keys to atoms and group duplicate keys into lists.
|
|
|
%% Keys that are not found in the user provided list are entirely skipped.
|