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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.4] - 2026-08-20

### Added

- `nhttp_h1:encode_response/2` takes `t:nhttp_h1:enc_opts/0`.
`#{content_length => omit}` suppresses the automatic `Content-Length`
field. A server that answers a `CONNECT` request with a 2xx status uses
it, because RFC 9110 Section 8.6 forbids the field there and the response
map carries no request method

### Fixed

- `nhttp_h1:encode_response/1` emits `Content-Length: 0` on a response with
an empty body. The call omitted the field before, so a client on a
persistent connection read the next response as content
- `nhttp_h1:encode_response/1` emits no `Content-Length` at a 1xx, 204, or
304 status (RFC 9110 Section 8.6). At 304 the field is valid only at the
length that a 200 response carries, which the encoder cannot compute, so a
caller that knows the value supplies it in the header list

## [1.0.3] - 2026-08-10

### Added
Expand Down
93 changes: 75 additions & 18 deletions src/nhttp_h1.erl
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ pattern applies to chunked requests.
encode_last_chunk/0,
encode_request/1,
encode_response/1,
encode_response/2,
encode_response_head/3
]).

Expand All @@ -128,6 +129,7 @@ pattern applies to chunked requests.
body_mode/0,
body_stream/0,
chunked_st/0,
enc_opts/0,
opts/0,
parse_error/0,
parse_result/1,
Expand Down Expand Up @@ -158,6 +160,21 @@ pattern applies to chunked requests.
| until_close
| none.

-doc """
Encoder options for `encode_response/2`.

`content_length` selects how the encoder frames a response:

- `auto` (the default) adds `Content-Length` when the header list carries
neither `content-length` nor `transfer-encoding`, and the status permits
the field.
- `omit` suppresses the automatic field at any status. A server that
answers a `CONNECT` request with a 2xx status uses it, because RFC 9110
Section 8.6 forbids the field there and the response map carries no
request method.
""".
-type enc_opts() :: #{content_length => auto | omit}.

-type opts() :: #{
max_header_size => pos_integer(),
max_headers_count => pos_integer(),
Expand Down Expand Up @@ -597,7 +614,8 @@ encode_request(#{method := Method, path := Path} = Req) ->
Version = maps:get(version, Req, http1_1),
Headers = maps:get(headers, Req, []),
Body = maps:get(body, Req, <<>>),
FinalHeaders = maybe_add_content_length(Headers, Body),
Len = iolist_size(Body),
FinalHeaders = maybe_add_content_length(Headers, Len, Len > 0),
[
nhttp_lib:encode_method(Method),
<<" ">>,
Expand All @@ -610,14 +628,42 @@ encode_request(#{method := Method, path := Path} = Req) ->
Body
].

-doc "Encode an HTTP/1.1 response to iolist.".
-doc """
Encode an HTTP/1.1 response to iolist.

Equivalent to `encode_response(Resp, #{})`. The encoder adds
`Content-Length` when the header list carries neither `content-length` nor
`transfer-encoding`, including a `Content-Length: 0` on an empty body.

The encoder adds no `Content-Length` at a 1xx, 204, or 304 status. RFC 9110
Section 8.6 forbids the field at 1xx and 204. It permits the field at 304
only at the length that a 200 response would have carried, which this
encoder cannot compute, so a caller that knows the value supplies it in the
header list.

A 2xx response to a `CONNECT` request also carries no `Content-Length`. The
response map holds no request method, so that case needs
`encode_response/2` with `#{content_length => omit}`.
""".
-spec encode_response(resp()) -> iolist().
encode_response(#{status := Status} = Resp) ->
encode_response(Resp) ->
encode_response(Resp, #{}).

-doc """
Encode an HTTP/1.1 response to iolist under the given encoder options.

See `encode_response/1` for the framing rules and `t:enc_opts/0` for the
options.
""".
-spec encode_response(resp(), enc_opts()) -> iolist().
encode_response(#{status := Status} = Resp, EncOpts) ->
Version = maps:get(version, Resp, http1_1),
Reason = maps:get(reason, Resp, <<>>),
Headers = maps:get(headers, Resp, []),
Body = maps:get(body, Resp, <<>>),
FinalHeaders = maybe_add_content_length(Headers, Body),
FinalHeaders = maybe_add_content_length(
Headers, iolist_size(Body), allows_content_length(Status, EncOpts)
),
[
encode_version(Version),
<<" ">>,
Expand Down Expand Up @@ -1024,23 +1070,34 @@ is_token_chars(<<C, Rest/binary>>) ->
is_valid_chunk_ext_tail(<<>>) -> true;
is_valid_chunk_ext_tail(Bin) -> skip_bws_to_semi(Bin).

-spec maybe_add_content_length(nhttp_lib:headers(), iodata()) -> nhttp_lib:headers().
maybe_add_content_length(Headers, Body) ->
case iolist_size(Body) of
0 ->
-spec maybe_add_content_length(nhttp_lib:headers(), non_neg_integer(), boolean()) ->
nhttp_lib:headers().
maybe_add_content_length(Headers, _Len, false) ->
Headers;
maybe_add_content_length(Headers, Len, true) ->
case
nhttp_headers:has(<<"content-length">>, Headers) orelse
nhttp_headers:has(<<"transfer-encoding">>, Headers)
of
true ->
Headers;
Len ->
case
nhttp_headers:has(<<"content-length">>, Headers) orelse
nhttp_headers:has(<<"transfer-encoding">>, Headers)
of
true ->
Headers;
false ->
[{<<"content-length">>, integer_to_binary(Len)} | Headers]
end
false ->
[{<<"content-length">>, integer_to_binary(Len)} | Headers]
end.

-spec allows_content_length(nhttp_lib:status(), enc_opts()) -> boolean().
allows_content_length(Status, EncOpts) ->
case maps:get(content_length, EncOpts, auto) of
auto -> not forbids_content_length(Status);
omit -> false
end.

-spec forbids_content_length(nhttp_lib:status()) -> boolean().
forbids_content_length(Status) when Status >= 100, Status =< 199 -> true;
forbids_content_length(204) -> true;
forbids_content_length(304) -> true;
forbids_content_length(_Status) -> false.

-spec parse_chunk_body_after_size(binary(), non_neg_integer(), non_neg_integer(), binary()) ->
{ok, binary(), pos_integer()}
| {final, pos_integer()}
Expand Down
2 changes: 1 addition & 1 deletion src/nhttp_lib.app.src
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{application, nhttp_lib, [
{description, "HTTP protocol primitives for Erlang/OTP 27+ (HTTP/1.1, HTTP/2, HTTP/3, QPACK)"},
{vsn, "1.0.3"},
{vsn, "1.0.4"},
{registered, []},
{applications, [
kernel,
Expand Down
128 changes: 112 additions & 16 deletions test/compliance/nhttp_h1_rfc9110_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ groups() ->
]},
{section_8_content_length, [parallel], [
no_content_length_1xx_204,
no_content_length_304,
content_length_zero_on_empty_body,
caller_content_length_survives_on_204,
no_content_length_with_transfer_encoding,
content_length_omit_opt_out,
reject_malformed_content_length
]},
{section_9_methods, [parallel], [
Expand Down Expand Up @@ -106,24 +111,88 @@ reject_or_replace_invalid_chars_in_field_value(_Config) ->
%%% Section 8 - Content-Length
%%%-----------------------------------------------------------------------------

%% RFC 9110 Section 8.6: "A server MUST NOT send a Content-Length header
%% field in any response with a status code of 1xx (Informational) or 204
%% (No Content)."
no_content_length_1xx_204(_Config) ->
Resp100 = #{
status => 100,
reason => <<"Continue">>,
headers => []
},
Io100 = nhttp_h1:encode_response(Resp100),
Encoded100 = iolist_to_binary(Io100),
?assertEqual(nomatch, binary:match(Encoded100, <<"Content-Length">>)),
lists:foreach(
fun({Status, Reason}) ->
Resp = #{status => Status, reason => Reason, headers => []},
Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp)),
?assertEqual(false, has_content_length(Encoded))
end,
[
{100, <<"Continue">>},
{101, <<"Switching Protocols">>},
{199, <<"Informational">>},
{204, <<"No Content">>}
]
).

%% RFC 9110 Section 8.6: a 304 permits Content-Length only at the length a
%% 200 response would have carried, which the encoder cannot compute.
no_content_length_304(_Config) ->
Resp = #{status => 304, reason => <<"Not Modified">>, headers => []},
Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp)),
?assertEqual(false, has_content_length(Encoded)).

%% RFC 9110 Section 8.6: "in the absence of Transfer-Encoding, an origin
%% server SHOULD send a Content-Length header field when the content size is
%% known prior to sending the complete header section."
content_length_zero_on_empty_body(_Config) ->
NoBodyKey = #{status => 200, reason => <<"OK">>, headers => []},
?assertEqual(
{true, <<"0">>},
content_length_value(iolist_to_binary(nhttp_h1:encode_response(NoBodyKey)))
),

EmptyBody = NoBodyKey#{body => <<>>},
?assertEqual(
{true, <<"0">>},
content_length_value(iolist_to_binary(nhttp_h1:encode_response(EmptyBody)))
),

NotFound = #{status => 404, reason => <<"Not Found">>, headers => [], body => <<>>},
?assertEqual(
{true, <<"0">>},
content_length_value(iolist_to_binary(nhttp_h1:encode_response(NotFound)))
).

Resp204 = #{
caller_content_length_survives_on_204(_Config) ->
Resp = #{
status => 204,
reason => <<"No Content">>,
headers => []
headers => [{<<"content-length">>, <<"42">>}]
},
Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp)),
?assertEqual({true, <<"42">>}, content_length_value(Encoded)),
?assertEqual(1, count_content_length(Encoded)).

no_content_length_with_transfer_encoding(_Config) ->
Resp = #{
status => 200,
reason => <<"OK">>,
headers => [{<<"transfer-encoding">>, <<"chunked">>}],
body => <<>>
},
Io204 = nhttp_h1:encode_response(Resp204),
Encoded204 = iolist_to_binary(Io204),
?assertEqual(nomatch, binary:match(Encoded204, <<"Content-Length">>)).
Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp)),
?assertEqual(false, has_content_length(Encoded)).

%% RFC 9110 Section 8.6: "A server MUST NOT send a Content-Length header
%% field in any 2xx (Successful) response to a CONNECT request."
content_length_omit_opt_out(_Config) ->
Resp = #{status => 200, reason => <<"Connection Established">>, headers => []},
Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp, #{content_length => omit})),
?assertEqual(false, has_content_length(Encoded)),

WithBody = Resp#{body => <<"hello">>},
EncodedWithBody = iolist_to_binary(
nhttp_h1:encode_response(WithBody, #{content_length => omit})
),
?assertEqual(false, has_content_length(EncodedWithBody)),

EncodedAuto = iolist_to_binary(nhttp_h1:encode_response(Resp, #{content_length => auto})),
?assertEqual({true, <<"0">>}, content_length_value(EncodedAuto)).

reject_malformed_content_length(_Config) ->
Req1 = <<
Expand Down Expand Up @@ -158,16 +227,18 @@ reject_malformed_content_length(_Config) ->
%%% Section 9 - Methods
%%%-----------------------------------------------------------------------------

%% RFC 9110 Section 8.6: a 2xx response to CONNECT carries no Content-Length.
%% The status alone does not identify the case, so the caller opts out.
no_body_headers_2xx_connect(_Config) ->
Resp = #{
status => 200,
reason => <<"Connection Established">>,
headers => []
},
Io = nhttp_h1:encode_response(Resp),
Io = nhttp_h1:encode_response(Resp, #{content_length => omit}),
Encoded = iolist_to_binary(Io),
?assertEqual(nomatch, binary:match(Encoded, <<"Content-Length">>)),
?assertEqual(nomatch, binary:match(Encoded, <<"Transfer-Encoding">>)).
?assertEqual(false, has_content_length(Encoded)),
?assertEqual(false, lists:keymember(<<"transfer-encoding">>, 1, encoded_headers(Encoded))).

client_ignore_body_headers_connect(_Config) ->
Resp = <<
Expand Down Expand Up @@ -215,6 +286,31 @@ no_body_in_304(_Config) ->
%%% Helpers
%%%-----------------------------------------------------------------------------

-spec encoded_headers(binary()) -> nhttp_lib:headers().
encoded_headers(Encoded) ->
[_StatusLine | Lines] = binary:split(Encoded, <<"\r\n">>, [global]),
[
{string:lowercase(Name), string:trim(Value, leading, " ")}
|| Line <- Lines,
Line =/= <<>>,
[Name, Value] <- [binary:split(Line, <<":">>)]
].

-spec has_content_length(binary()) -> boolean().
has_content_length(Encoded) ->
lists:keymember(<<"content-length">>, 1, encoded_headers(Encoded)).

-spec content_length_value(binary()) -> {true, binary()} | false.
content_length_value(Encoded) ->
case lists:keyfind(<<"content-length">>, 1, encoded_headers(Encoded)) of
{_, Value} -> {true, Value};
false -> false
end.

-spec count_content_length(binary()) -> non_neg_integer().
count_content_length(Encoded) ->
length([V || {<<"content-length">>, V} <- encoded_headers(Encoded)]).

-spec find_header(binary(), nhttp_lib:headers()) -> {ok, binary()} | error.
find_header(Name, Headers) ->
LowerName = string:lowercase(Name),
Expand Down
3 changes: 2 additions & 1 deletion test/nhttp_h1_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,8 @@ encode_response_no_body(_Config) ->
},
IOList = nhttp_h1:encode_response(Resp),
Bin = iolist_to_binary(IOList),
?assertMatch(<<"HTTP/1.1 204 No Content\r\n", _/binary>>, Bin).
?assertMatch(<<"HTTP/1.1 204 No Content\r\n", _/binary>>, Bin),
?assertEqual(nomatch, re:run(Bin, <<"content-length">>, [caseless])).

encode_response_head(_Config) ->
IOList = nhttp_h1:encode_response_head(
Expand Down
10 changes: 9 additions & 1 deletion test/property_test/nhttp_h1_props.erl
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,18 @@ h1_resp_gen() ->
status => Status,
reason => Reason,
headers => Headers,
body => Body
body => body_for_status(Status, Body)
}
).

-spec body_for_status(nhttp_lib:status(), binary()) -> binary().
body_for_status(Status, _Body) when
Status >= 100, Status =< 199; Status =:= 204; Status =:= 304
->
<<>>;
body_for_status(_Status, Body) ->
Body.

-spec method_gen() -> triq_dom:domain().
method_gen() ->
oneof([get, post, put, delete, head, options, patch]).
Expand Down
Loading