diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index 625fb41c..6cc9024c 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -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), @@ -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 @@ -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). diff --git a/test/hackney_conn_content_length_tests.erl b/test/hackney_conn_content_length_tests.erl new file mode 100644 index 00000000..2bcde074 --- /dev/null +++ b/test/hackney_conn_content_length_tests.erl @@ -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: " 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, <>); + {error, _} -> Acc + end; + _ -> Acc + end.