diff --git a/lib/bandit/compression.ex b/lib/bandit/compression.ex index dc39e585..cf18875a 100644 --- a/lib/bandit/compression.ex +++ b/lib/bandit/compression.ex @@ -11,7 +11,7 @@ defmodule Bandit.Compression do |> Enum.find(&(&1 in ~w(deflate gzip x-gzip))) end - @spec compress(binary(), String.t(), Bandit.deflate_options()) :: iodata() + @spec compress(iolist(), String.t(), Bandit.deflate_options()) :: iodata() def compress(response, "deflate", opts) do deflate_context = :zlib.open() diff --git a/lib/bandit/http1/adapter.ex b/lib/bandit/http1/adapter.ex index f9e55184..56da1e52 100644 --- a/lib/bandit/http1/adapter.ex +++ b/lib/bandit/http1/adapter.ex @@ -369,13 +369,15 @@ defmodule Bandit.HTTP1.Adapter do header -> "no-transform" in Plug.Conn.Utils.list(header) end + raw_body_bytes = IO.iodata_length(body) + {body, headers, compression_metrics} = case {body, req.content_encoding, response_content_encoding_header, response_has_strong_etag, response_indicates_no_transform} do {body, content_encoding, nil, false, false} - when byte_size(body) > 0 and not is_nil(content_encoding) -> + when raw_body_bytes > 0 and not is_nil(content_encoding) -> metrics = %{ - resp_uncompressed_body_bytes: IO.iodata_length(body), + resp_uncompressed_body_bytes: raw_body_bytes, resp_compression_method: content_encoding } diff --git a/lib/bandit/http2/adapter.ex b/lib/bandit/http2/adapter.ex index d4bc37fc..bf891125 100644 --- a/lib/bandit/http2/adapter.ex +++ b/lib/bandit/http2/adapter.ex @@ -120,13 +120,15 @@ defmodule Bandit.HTTP2.Adapter do header -> "no-transform" in Plug.Conn.Utils.list(header) end + raw_body_bytes = IO.iodata_length(body) + {body, headers, compression_metrics} = case {body, adapter.content_encoding, response_content_encoding_header, response_has_strong_etag, response_indicates_no_transform} do {body, content_encoding, nil, false, false} - when body != <<>> and not is_nil(content_encoding) -> + when raw_body_bytes > 0 and not is_nil(content_encoding) -> metrics = %{ - resp_uncompressed_body_bytes: IO.iodata_length(body), + resp_uncompressed_body_bytes: raw_body_bytes, resp_compression_method: content_encoding } @@ -215,7 +217,7 @@ defmodule Bandit.HTTP2.Adapter do # details) and closing the stream here carves closest to the underlying HTTP/1.1 behaviour # (RFC9112§7.1). The whole notion of chunked encoding is moot in HTTP/2 anyway (RFC9113§8.1) # so this entire section of the API is a bit slanty regardless. - _ = send_data(adapter, chunk, chunk == <<>>) + _ = send_data(adapter, chunk, IO.iodata_length(chunk) == 0) :ok end diff --git a/lib/bandit/http2/connection.ex b/lib/bandit/http2/connection.ex index baea477e..5b001aa9 100644 --- a/lib/bandit/http2/connection.ex +++ b/lib/bandit/http2/connection.ex @@ -485,7 +485,7 @@ defmodule Bandit.HTTP2.Connection do {data_to_send, bytes_to_send, rest} <- split_data(data, max_bytes_to_send), {:ok, stream} <- Stream.send_data(stream, bytes_to_send), connection <- %{connection | send_window_size: connection_window_size - bytes_to_send}, - end_stream_to_send <- end_stream && rest == <<>>, + end_stream_to_send <- end_stream && byte_size(rest) == 0, {:ok, stream} <- Stream.send_end_of_stream(stream, end_stream_to_send), {:ok, streams} <- StreamCollection.put_stream(connection.streams, stream) do _ = diff --git a/test/bandit/http1/request_test.exs b/test/bandit/http1/request_test.exs index f9f23176..dd9d0da3 100644 --- a/test/bandit/http1/request_test.exs +++ b/test/bandit/http1/request_test.exs @@ -901,6 +901,26 @@ defmodule HTTP1RequestTest do assert response.body == expected end + test "writes out an encoded response for an iolist body", context do + response = + Req.get!(context.req, url: "/send_iolist_body", headers: [{"accept-encoding", "deflate"}]) + + assert response.status == 200 + assert response.headers["content-length"] == ["34"] + assert response.headers["content-encoding"] == ["deflate"] + assert response.headers["vary"] == ["accept-encoding"] + + deflate_context = :zlib.open() + :ok = :zlib.deflateInit(deflate_context) + + expected = + deflate_context + |> :zlib.deflate(String.duplicate("a", 10_000), :sync) + |> IO.iodata_to_binary() + + assert response.body == expected + end + test "falls back to no encoding if no encodings provided", context do response = Req.get!(context.req, url: "/send_big_body") @@ -1003,6 +1023,12 @@ defmodule HTTP1RequestTest do |> send_resp(200, String.duplicate("a", 10_000)) end + def send_iolist_body(conn) do + conn + |> put_resp_header("content-length", "10000") + |> send_resp(200, List.duplicate("a", 10_000)) + end + def send_content_encoding(conn) do conn |> put_resp_header("content-encoding", "deflate") @@ -1119,6 +1145,23 @@ defmodule HTTP1RequestTest do conn end + test "writes out a chunked iolist response", context do + response = Req.get!(context.req, url: "/send_chunked_200_iolist") + + assert response.status == 200 + assert response.body == "OK" + assert response.headers["transfer-encoding"] == ["chunked"] + end + + def send_chunked_200_iolist(conn) do + {:ok, conn} = + conn + |> send_chunked(200) + |> chunk(["OK"]) + + conn + end + test "returns socket errors on chunk calls", context do client = SimpleHTTP1Client.tcp_client(context) diff --git a/test/bandit/http2/protocol_test.exs b/test/bandit/http2/protocol_test.exs index 7d07cd5b..7a79cf6e 100644 --- a/test/bandit/http2/protocol_test.exs +++ b/test/bandit/http2/protocol_test.exs @@ -264,6 +264,40 @@ defmodule HTTP2ProtocolTest do assert SimpleH2Client.recv_body(socket) == {:ok, 1, true, expected} end + test "writes out a response with deflate encoding for an iolist body", context do + socket = SimpleH2Client.setup_connection(context) + + headers = [ + {":method", "GET"}, + {":path", "/send_iolist_body"}, + {":scheme", "https"}, + {":authority", "localhost:#{context.port}"}, + {"accept-encoding", "deflate"} + ] + + SimpleH2Client.send_headers(socket, 1, true, headers) + + assert {:ok, 1, false, + [ + {":status", "200"}, + {"date", _date}, + {"content-length", "34"}, + {"vary", "accept-encoding"}, + {"content-encoding", "deflate"}, + {"cache-control", "max-age=0, private, must-revalidate"} + ], _ctx} = SimpleH2Client.recv_headers(socket) + + deflate_context = :zlib.open() + :ok = :zlib.deflateInit(deflate_context) + + expected = + deflate_context + |> :zlib.deflate(String.duplicate("a", 10_000), :sync) + |> IO.iodata_to_binary() + + assert SimpleH2Client.recv_body(socket) == {:ok, 1, true, expected} + end + test "does no encoding if content-encoding header already present in response", context do socket = SimpleH2Client.setup_connection(context) @@ -454,6 +488,11 @@ defmodule HTTP2ProtocolTest do |> send_resp(200, String.duplicate("a", 10_000)) end + def send_iolist_body(conn) do + conn + |> send_resp(200, List.duplicate("a", 10_000)) + end + def send_content_encoding(conn) do conn |> put_resp_header("content-encoding", "deflate") @@ -529,6 +568,26 @@ defmodule HTTP2ProtocolTest do |> elem(1) end + test "sends multiple DATA frames when sending iolist chunks", context do + socket = SimpleH2Client.setup_connection(context) + + SimpleH2Client.send_simple_headers(socket, 1, :get, "/iolist_chunk_response", context.port) + + assert SimpleH2Client.successful_response?(socket, 1, false) + assert SimpleH2Client.recv_body(socket) == {:ok, 1, false, "OK"} + assert SimpleH2Client.recv_body(socket) == {:ok, 1, false, "DOKEE"} + assert SimpleH2Client.recv_body(socket) == {:ok, 1, true, ""} + end + + def iolist_chunk_response(conn) do + conn + |> send_chunked(200) + |> chunk(["OK"]) + |> elem(1) + |> chunk(["DOKEE"]) + |> elem(1) + end + test "reads a zero byte body if none is sent", context do socket = SimpleH2Client.setup_connection(context)