diff --git a/lib/tesla/middleware/compression.ex b/lib/tesla/middleware/compression.ex index 93fe3a2c..ee5c819c 100644 --- a/lib/tesla/middleware/compression.ex +++ b/lib/tesla/middleware/compression.ex @@ -67,9 +67,14 @@ defmodule Tesla.Middleware.Compression do def decompress({:ok, env}), do: {:ok, decompress(env)} def decompress({:error, reason}), do: {:error, reason} + # HEAD requests may be used to obtain information on the transfer size and properties + # and their empty bodies are not actually valid for the possibly indicated encodings + # thus we want to preserve them unchanged. + def decompress(%Tesla.Env{method: :head} = env), do: env + def decompress(env) do codecs = compression_algorithms(Tesla.get_header(env, "content-encoding")) - {decompressed_body, unknown_codecs} = decompress_body(codecs, env.body, []) + {decompressed_body, unknown_codecs} = decompress_body(codecs, env.body) env |> put_decompressed_body(decompressed_body) @@ -84,28 +89,24 @@ defmodule Tesla.Middleware.Compression do Tesla.put_header(env, "content-encoding", Enum.join(unknown_codecs, ", ")) end - defp decompress_body(_codecs, "" = body, acc) do - {body, acc} - end - - defp decompress_body([gzip | rest], body, acc) when gzip in ["gzip", "x-gzip"] do - decompress_body(rest, :zlib.gunzip(body), acc) + defp decompress_body([gzip | rest], body) when gzip in ["gzip", "x-gzip"] do + decompress_body(rest, :zlib.gunzip(body)) end - defp decompress_body(["deflate" | rest], body, acc) do - decompress_body(rest, :zlib.unzip(body), acc) + defp decompress_body(["deflate" | rest], body) do + decompress_body(rest, :zlib.unzip(body)) end - defp decompress_body(["identity" | rest], body, acc) do - decompress_body(rest, body, acc) + defp decompress_body(["identity" | rest], body) do + decompress_body(rest, body) end - defp decompress_body([codec | rest], body, acc) do - decompress_body(rest, body, [codec | acc]) + defp decompress_body([codec | rest], body) do + {body, Enum.reverse([codec | rest])} end - defp decompress_body([], body, acc) do - {body, acc} + defp decompress_body([], body) do + {body, []} end defp compression_algorithms(nil) do @@ -123,7 +124,27 @@ defmodule Tesla.Middleware.Compression do defp put_decompressed_body(env, body) do env |> Tesla.put_body(body) - |> Tesla.delete_header("content-length") + |> update_content_length(body) + end + + # The value of the content-length header wil be inaccurate after decompression. + # But setting it is mandatory or strongly encouraged in HTTP/1.0 and HTTP/1.1. + # Except, when transfer-encoding is used defining content-length is invalid. + # Thus we can neither just drop it nor indiscriminately add it, but will update it if it already exist. + # Furthermore, content-length is technically allowed to be specified mutliple times if all values match, + # to ensure consistency we must therefore make sure to drop any duplicate definitions while updating. + defp update_content_length(env, body) when is_binary(body) do + if Tesla.get_header(env, "content-length") != nil do + env + |> Tesla.delete_header("content-length") + |> Tesla.put_header("content-length", "#{byte_size(body)}") + else + env + end + end + + defp update_content_length(env, _) do + env end end diff --git a/test/tesla/middleware/compression_test.exs b/test/tesla/middleware/compression_test.exs index 44d2a739..9b0f5be7 100644 --- a/test/tesla/middleware/compression_test.exs +++ b/test/tesla/middleware/compression_test.exs @@ -59,11 +59,33 @@ defmodule Tesla.Middleware.CompressionTest do {200, [{"content-type", "text/plain"}, {"content-encoding", "deflate"}], :zlib.zip("decompressed deflate")} + "/multiple-encodings" -> + {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip, zstd, gzip"}], + :zlib.gzip("decompressed gzip")} + "/response-identity" -> {200, [{"content-type", "text/plain"}, {"content-encoding", "identity"}], "unchanged"} "/response-empty" -> {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip"}], ""} + + "/response-with-content-length" -> + body = :zlib.gzip("decompressed gzip") + + {200, + [ + {"content-type", "text/plain"}, + {"content-encoding", "gzip"}, + {"content-length", "#{byte_size(body)}"} + ], body} + + "/response-empty-with-content-length" -> + {200, + [ + {"content-type", "text/plain"}, + {"content-encoding", "gzip"}, + {"content-length", "4194304"} + ], ""} end {:ok, %{env | status: status, headers: headers, body: body}} @@ -81,16 +103,44 @@ defmodule Tesla.Middleware.CompressionTest do assert env.body == "decompressed deflate" end + test "stops decompressing on first unsupported content-encoding" do + assert {:ok, env} = CompressionResponseClient.get("/multiple-encodings") + assert env.body == "decompressed gzip" + assert env.headers == [{"content-type", "text/plain"}, {"content-encoding", "gzip, zstd"}] + end + test "return unchanged response for unsupported content-encoding" do assert {:ok, env} = CompressionResponseClient.get("/response-identity") assert env.body == "unchanged" assert env.headers == [{"content-type", "text/plain"}] end - test "return unchanged response for empty body (gzip)" do - assert {:ok, env} = CompressionResponseClient.get("/response-empty") + test "raises on invalid empty-body response (gzip)" do + assert_raise(ErlangError, "Erlang error: :data_error", fn -> + CompressionResponseClient.get("/response-empty") + end) + end + + test "updates existing content-length header" do + expected_body = "decompressed gzip" + assert {:ok, env} = CompressionResponseClient.get("/response-with-content-length") + assert env.body == expected_body + + assert env.headers == [ + {"content-type", "text/plain"}, + {"content-length", "#{byte_size(expected_body)}"} + ] + end + + test "preserves compression headers for HEAD requests" do + assert {:ok, env} = CompressionResponseClient.head("/response-empty-with-content-length") assert env.body == "" - assert env.headers == [{"content-type", "text/plain"}] + + assert env.headers == [ + {"content-type", "text/plain"}, + {"content-encoding", "gzip"}, + {"content-length", "4194304"} + ] end defmodule CompressRequestDecompressResponseClient do