Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/hackney_conn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -2115,7 +2115,7 @@ compute_netloc(Host, Port, Transport) ->
end.

%% @private Build request headers
build_headers(_Method, Headers0, Body, Netloc) ->
build_headers(Method, Headers0, Body, Netloc) ->
%% Start with user headers
Headers1 = hackney_headers:new(Headers0),

Expand All @@ -2127,8 +2127,16 @@ build_headers(_Method, Headers0, Body, Netloc) ->

%% Add Content-Length for bodies
case Body of
<<>> -> Headers3;
[] -> Headers3;
B when B =:= <<>>; B =:= [] ->
%% Empty body: like curl, send Content-Length: 0 for methods that
%% carry a body (POST/PUT/PATCH) so a server that requires the
%% header (e.g. AWS) still gets it; leave bodyless methods
%% (GET/HEAD/DELETE/...) without one.
case body_method(Method) andalso
not hackney_headers:is_key(<<"content-length">>, Headers3) of
true -> hackney_headers:store(<<"Content-Length">>, <<"0">>, Headers3);
false -> Headers3
end;
_ when is_binary(Body) ->
Len = byte_size(Body),
case hackney_headers:is_key(<<"content-length">>, Headers3) of
Expand All @@ -2148,6 +2156,16 @@ build_headers(_Method, Headers0, Body, Netloc) ->
Headers3
end.

%% @private Methods that carry a request body, so an empty body still gets an
%% explicit Content-Length: 0 (curl does the same for POST/PUT/PATCH).
body_method(Method) ->
case hackney_bstr:to_upper(hackney_bstr:to_binary(Method)) of
<<"POST">> -> true;
<<"PUT">> -> true;
<<"PATCH">> -> true;
_ -> false
end.

%% @private Convert headers to binary
headers_to_binary(Headers) ->
hackney_headers:to_binary(Headers).
Expand Down
72 changes: 72 additions & 0 deletions test/hackney_conn_content_length_tests.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
%%% Content-Length on empty request bodies, curl-style: body-bearing methods
%%% (POST/PUT/PATCH) get an explicit Content-Length: 0; bodyless methods do not,
%%% and a caller-supplied Content-Length is never duplicated.
-module(hackney_conn_content_length_tests).

-include_lib("eunit/include/eunit.hrl").

post_empty_body_gets_content_length_zero_test() ->
Req = capture(<<"POST">>, [], <<>>),
?assertNotEqual(nomatch, cl(Req, <<"0">>)).

put_empty_body_gets_content_length_zero_test() ->
Req = capture(<<"PUT">>, [], <<>>),
?assertNotEqual(nomatch, cl(Req, <<"0">>)).

get_empty_body_has_no_content_length_test() ->
Req = capture(<<"GET">>, [], <<>>),
?assertEqual(nomatch, binary:match(lower(Req), <<"content-length:">>)).

delete_empty_body_has_no_content_length_test() ->
Req = capture(<<"DELETE">>, [], <<>>),
?assertEqual(nomatch, binary:match(lower(Req), <<"content-length:">>)).

user_content_length_not_duplicated_test() ->
Req = capture(<<"POST">>, [{<<"Content-Length">>, <<"5">>}], <<>>),
%% Exactly one content-length header, and it is the caller's value.
Matches = binary:matches(lower(Req), <<"content-length:">>),
?assertEqual(1, length(Matches)),
?assertNotEqual(nomatch, cl(Req, <<"5">>)).

%% Find "content-length: <V>" case-insensitively.
cl(Req, V) ->
binary:match(lower(Req), <<"content-length: ", V/binary, "\r\n">>).

lower(Bin) -> hackney_bstr:to_lower(Bin).

%% Drive a real buffered request into a raw listener and return the request
%% bytes the server received.
capture(Method, Headers, Body) ->
{ok, _} = application:ensure_all_started(hackney),
{ok, LSock} = gen_tcp:listen(0, [binary, {active, false},
{reuseaddr, true}, {ip, {127, 0, 0, 1}}]),
{ok, Port} = inet:port(LSock),
Parent = self(),
spawn(fun() ->
case gen_tcp:accept(LSock, 3000) of
{ok, S} ->
ReqBytes = recv_headers(S, <<>>),
gen_tcp:send(S, <<"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n">>),
Parent ! {captured, ReqBytes},
gen_tcp:close(S);
_ -> Parent ! {captured, <<>>}
end,
gen_tcp:close(LSock)
end),
{ok, Pid} = hackney_conn:start_link(#{host => "127.0.0.1", port => Port,
transport => hackney_tcp,
connect_timeout => 1000}),
ok = hackney_conn:connect(Pid, 1000),
{ok, 200, _} = hackney_conn:request(Pid, Method, <<"/">>, Headers, Body),
catch hackney_conn:stop(Pid),
receive {captured, Req} -> Req after 3000 -> error(no_capture) end.

recv_headers(S, Acc) ->
case binary:match(Acc, <<"\r\n\r\n">>) of
nomatch ->
case gen_tcp:recv(S, 0, 2000) of
{ok, D} -> recv_headers(S, <<Acc/binary, D/binary>>);
{error, _} -> Acc
end;
_ -> Acc
end.
Loading