Skip to content

Commit 0516b9a

Browse files
authored
Merge pull request #925 from benoitc/fix/content-length-zero-empty-body
Send Content-Length: 0 for empty POST/PUT/PATCH bodies
2 parents 8b28c63 + 831281e commit 0516b9a

2 files changed

Lines changed: 93 additions & 3 deletions

File tree

src/hackney_conn.erl

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,7 +2157,7 @@ compute_netloc(Host, Port, Transport) ->
21572157
end.
21582158

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

@@ -2169,8 +2169,16 @@ build_headers(_Method, Headers0, Body, Netloc) ->
21692169

21702170
%% Add Content-Length for bodies
21712171
case Body of
2172-
<<>> -> Headers3;
2173-
[] -> Headers3;
2172+
B when B =:= <<>>; B =:= [] ->
2173+
%% Empty body: like curl, send Content-Length: 0 for methods that
2174+
%% carry a body (POST/PUT/PATCH) so a server that requires the
2175+
%% header (e.g. AWS) still gets it; leave bodyless methods
2176+
%% (GET/HEAD/DELETE/...) without one.
2177+
case body_method(Method) andalso
2178+
not hackney_headers:is_key(<<"content-length">>, Headers3) of
2179+
true -> hackney_headers:store(<<"Content-Length">>, <<"0">>, Headers3);
2180+
false -> Headers3
2181+
end;
21742182
_ when is_binary(Body) ->
21752183
Len = byte_size(Body),
21762184
case hackney_headers:is_key(<<"content-length">>, Headers3) of
@@ -2190,6 +2198,16 @@ build_headers(_Method, Headers0, Body, Netloc) ->
21902198
Headers3
21912199
end.
21922200

2201+
%% @private Methods that carry a request body, so an empty body still gets an
2202+
%% explicit Content-Length: 0 (curl does the same for POST/PUT/PATCH).
2203+
body_method(Method) ->
2204+
case hackney_bstr:to_upper(hackney_bstr:to_binary(Method)) of
2205+
<<"POST">> -> true;
2206+
<<"PUT">> -> true;
2207+
<<"PATCH">> -> true;
2208+
_ -> false
2209+
end.
2210+
21932211
%% @private Convert headers to binary
21942212
headers_to_binary(Headers) ->
21952213
hackney_headers:to_binary(Headers).
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
%%% Content-Length on empty request bodies, curl-style: body-bearing methods
2+
%%% (POST/PUT/PATCH) get an explicit Content-Length: 0; bodyless methods do not,
3+
%%% and a caller-supplied Content-Length is never duplicated.
4+
-module(hackney_conn_content_length_tests).
5+
6+
-include_lib("eunit/include/eunit.hrl").
7+
8+
post_empty_body_gets_content_length_zero_test() ->
9+
Req = capture(<<"POST">>, [], <<>>),
10+
?assertNotEqual(nomatch, cl(Req, <<"0">>)).
11+
12+
put_empty_body_gets_content_length_zero_test() ->
13+
Req = capture(<<"PUT">>, [], <<>>),
14+
?assertNotEqual(nomatch, cl(Req, <<"0">>)).
15+
16+
get_empty_body_has_no_content_length_test() ->
17+
Req = capture(<<"GET">>, [], <<>>),
18+
?assertEqual(nomatch, binary:match(lower(Req), <<"content-length:">>)).
19+
20+
delete_empty_body_has_no_content_length_test() ->
21+
Req = capture(<<"DELETE">>, [], <<>>),
22+
?assertEqual(nomatch, binary:match(lower(Req), <<"content-length:">>)).
23+
24+
user_content_length_not_duplicated_test() ->
25+
Req = capture(<<"POST">>, [{<<"Content-Length">>, <<"5">>}], <<>>),
26+
%% Exactly one content-length header, and it is the caller's value.
27+
Matches = binary:matches(lower(Req), <<"content-length:">>),
28+
?assertEqual(1, length(Matches)),
29+
?assertNotEqual(nomatch, cl(Req, <<"5">>)).
30+
31+
%% Find "content-length: <V>" case-insensitively.
32+
cl(Req, V) ->
33+
binary:match(lower(Req), <<"content-length: ", V/binary, "\r\n">>).
34+
35+
lower(Bin) -> hackney_bstr:to_lower(Bin).
36+
37+
%% Drive a real buffered request into a raw listener and return the request
38+
%% bytes the server received.
39+
capture(Method, Headers, Body) ->
40+
{ok, _} = application:ensure_all_started(hackney),
41+
{ok, LSock} = gen_tcp:listen(0, [binary, {active, false},
42+
{reuseaddr, true}, {ip, {127, 0, 0, 1}}]),
43+
{ok, Port} = inet:port(LSock),
44+
Parent = self(),
45+
spawn(fun() ->
46+
case gen_tcp:accept(LSock, 3000) of
47+
{ok, S} ->
48+
ReqBytes = recv_headers(S, <<>>),
49+
gen_tcp:send(S, <<"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n">>),
50+
Parent ! {captured, ReqBytes},
51+
gen_tcp:close(S);
52+
_ -> Parent ! {captured, <<>>}
53+
end,
54+
gen_tcp:close(LSock)
55+
end),
56+
{ok, Pid} = hackney_conn:start_link(#{host => "127.0.0.1", port => Port,
57+
transport => hackney_tcp,
58+
connect_timeout => 1000}),
59+
ok = hackney_conn:connect(Pid, 1000),
60+
{ok, 200, _} = hackney_conn:request(Pid, Method, <<"/">>, Headers, Body),
61+
catch hackney_conn:stop(Pid),
62+
receive {captured, Req} -> Req after 3000 -> error(no_capture) end.
63+
64+
recv_headers(S, Acc) ->
65+
case binary:match(Acc, <<"\r\n\r\n">>) of
66+
nomatch ->
67+
case gen_tcp:recv(S, 0, 2000) of
68+
{ok, D} -> recv_headers(S, <<Acc/binary, D/binary>>);
69+
{error, _} -> Acc
70+
end;
71+
_ -> Acc
72+
end.

0 commit comments

Comments
 (0)