diff --git a/lib/plum.rb b/lib/plum.rb index 3ae301f..95e5588 100644 --- a/lib/plum.rb +++ b/lib/plum.rb @@ -2,6 +2,7 @@ require "socket" require "base64" require "set" +require "zlib" require "plum/version" require "plum/errors" require "plum/binary_string" @@ -24,6 +25,7 @@ require "plum/server/http_connection" require "plum/client" require "plum/client/response" +require "plum/client/decoders" require "plum/client/connection" require "plum/client/client_session" require "plum/client/legacy_client_session" diff --git a/lib/plum/client.rb b/lib/plum/client.rb index c2a1b83..8cb9230 100644 --- a/lib/plum/client.rb +++ b/lib/plum/client.rb @@ -9,6 +9,7 @@ class Client ssl_context: nil, http2_settings: {}, user_agent: "plum/#{Plum::VERSION}", + auto_decode: true, }.freeze attr_reader :host, :port, :config @@ -79,7 +80,7 @@ def close # @param block [Proc] if passed, it will be called when received response headers. def request(headers, body, options = {}, &block) raise ArgumentError, ":method and :path headers are required" unless headers[":method"] && headers[":path"] - @session.request(headers, body, options, &block) + @session.request(headers, body, @config.merge(options), &block) end # @!method get! diff --git a/lib/plum/client/client_session.rb b/lib/plum/client/client_session.rb index 495d099..6e9fc56 100644 --- a/lib/plum/client/client_session.rb +++ b/lib/plum/client/client_session.rb @@ -42,7 +42,7 @@ def request(headers, body, options, &headers_cb) ":scheme" => @config[:scheme] }.merge(headers) - response = Response.new + response = Response.new(**options) @responses << response stream = @plum.open_stream stream.send_headers(headers, end_stream: !body) diff --git a/lib/plum/client/decoders.rb b/lib/plum/client/decoders.rb new file mode 100644 index 0000000..e6d72e7 --- /dev/null +++ b/lib/plum/client/decoders.rb @@ -0,0 +1,51 @@ +module Plum + module Decoders + class Base + def decode(chunk) + chunk + end + + def finish + end + end + + # `deflate` is not just deflate, wrapped by zlib format (RFC 1950) + class Deflate < Base + def initialize + @inflate = Zlib::Inflate.new(Zlib::MAX_WBITS) + end + + def decode(chunk) + @inflate.inflate(chunk) + rescue Zlib::Error => e + raise DecoderError.new("failed to decode chunk", e) + end + + def finish + @inflate.finish + rescue Zlib::Error => e + raise DecoderError.new("failed to finalize", e) + end + end + + class GZip < Base + def initialize + @stream = Zlib::Inflate.new(Zlib::MAX_WBITS + 16) + end + + def decode(chunk) + @stream.inflate(chunk) + rescue Zlib::Error => e + raise DecoderError.new("failed to decode chunk", e) + end + + def finish + @stream.finish + rescue Zlib::Error => e + raise DecoderError.new("failed to finalize", e) + end + end + + DECODERS = { "gzip" => GZip, "deflate" => Deflate }.freeze + end +end diff --git a/lib/plum/client/legacy_client_session.rb b/lib/plum/client/legacy_client_session.rb index a30c237..bc531ac 100644 --- a/lib/plum/client/legacy_client_session.rb +++ b/lib/plum/client/legacy_client_session.rb @@ -40,7 +40,7 @@ def request(headers, body, options, &headers_cb) end end - response = Response.new + response = Response.new(**options) @requests << [response, headers, body, chunked, headers_cb] consume_queue response diff --git a/lib/plum/client/response.rb b/lib/plum/client/response.rb index e9bd5e1..9e50f02 100644 --- a/lib/plum/client/response.rb +++ b/lib/plum/client/response.rb @@ -6,11 +6,12 @@ class Response attr_reader :headers # @api private - def initialize + def initialize(auto_decode: true, **options) @body = Queue.new @finished = false @failed = false @body = [] + @auto_decode = auto_decode end # Returns the HTTP status code. @@ -54,7 +55,7 @@ def on_chunk(&block) def on_finish(&block) raise ArgumentError, "block must be given" unless block_given? if finished? - block.call + yield else @on_finish = block end @@ -64,21 +65,20 @@ def on_finish(&block) # @return [String] the whole response body def body raise "Body already read" if @on_chunk - if finished? - @body.join - else - raise "Response body is not complete" - end + raise "Response body is not complete" unless finished? + @body.join end # @api private def _headers(raw_headers) # response headers should not have duplicates @headers = raw_headers.to_h.freeze + @decoder = setup_decoder end # @api private - def _chunk(chunk) + def _chunk(encoded) + chunk = @decoder.decode(encoded) if @on_chunk @on_chunk.call(chunk) else @@ -89,6 +89,7 @@ def _chunk(chunk) # @api private def _finish @finished = true + @decoder.finish @on_finish.call if @on_finish end @@ -96,5 +97,14 @@ def _finish def _fail @failed = true end + + private + def setup_decoder + if @auto_decode + klass = Decoders::DECODERS[@headers["content-encoding"]] + end + klass ||= Decoders::Base + klass.new + end end end diff --git a/lib/plum/errors.rb b/lib/plum/errors.rb index 3220c67..26fade1 100644 --- a/lib/plum/errors.rb +++ b/lib/plum/errors.rb @@ -31,6 +31,14 @@ def http2_error_code ERROR_CODES[@http2_error_type] end end + + class RemoteHTTPError < HTTPError; end + class RemoteConnectionError < RemoteHTTPError; end + class RemoteStreamError < RemoteHTTPError; end + class LocalHTTPError < HTTPError; end + class LocalConnectionError < LocalHTTPError; end + class LocalStreamError < LocalHTTPError; end + class LegacyHTTPError < Error attr_reader :headers, :data, :parser @@ -41,10 +49,12 @@ def initialize(headers, data, parser) end end - class RemoteHTTPError < HTTPError; end - class RemoteConnectionError < RemoteHTTPError; end - class RemoteStreamError < RemoteHTTPError; end - class LocalHTTPError < HTTPError; end - class LocalConnectionError < LocalHTTPError; end - class LocalStreamError < LocalHTTPError; end + class DecoderError < Error + attr_reader :inner_error + + def initialize(message, inner_error = nil) + super(message) + @inner_error = inner_error + end + end end diff --git a/test/plum/client/test_decoders.rb b/test/plum/client/test_decoders.rb new file mode 100644 index 0000000..dfd67b1 --- /dev/null +++ b/test/plum/client/test_decoders.rb @@ -0,0 +1,54 @@ +require "test_helper" + +using Plum::BinaryString +class DecodersTest < Minitest::Test + def test_base_decode + decoder = Decoders::Base.new + assert_equal("abc", decoder.decode("abc")) + end + + def test_base_finish + decoder = Decoders::Base.new + decoder.finish + end + + def test_deflate_decode + decoder = Decoders::Deflate.new + assert_equal("hello", decoder.decode("\x78\x9c\xcb\x48\xcd\xc9\xc9\x07\x00\x06\x2c\x02\x15")) + end + + def test_deflate_decode_error + decoder = Decoders::Deflate.new + assert_raises(DecoderError) { + decoder.decode("\x79\x9c\xcb\x48\xcd\xc9\xc9\x07\x00\x06\x2c\x02\x15") + } + end + + def test_deflate_finish_error + decoder = Decoders::Deflate.new + decoder.decode("\x78\x9c\xcb\x48\xcd\xc9\xc9\x07\x00\x06\x2c\x02") + assert_raises(DecoderError) { + decoder.finish + } + end + + def test_gzip_decode + decoder = Decoders::GZip.new + assert_equal("hello", decoder.decode("\x1f\x8b\x08\x00\x1a\x96\xe0\x4c\x00\x03\xcb\x48\xcd\xc9\xc9\x07\x00\x86\xa6\x10\x36\x05\x00\x00\x00")) + end + + def test_gzip_decode_error + decoder = Decoders::GZip.new + assert_raises(DecoderError) { + decoder.decode("\x2f\x8b\x08\x00\x1a\x96\xe0\x4c\x00\x03\xcb\x48\xcd\xc9\xc9\x07\x00\x86\xa6\x10\x36\x05\x00\x00\x00") + } + end + + def test_gzip_finish_error + decoder = Decoders::GZip.new + decoder.decode("\x1f\x8b\x08\x00\x1a\x96") + assert_raises(DecoderError) { + decoder.finish + } + end +end diff --git a/test/plum/client/test_response.rb b/test/plum/client/test_response.rb index 76d9037..511a073 100644 --- a/test/plum/client/test_response.rb +++ b/test/plum/client/test_response.rb @@ -4,6 +4,7 @@ class ResponseTest < Minitest::Test def test_finished resp = Response.new + resp._headers({}) assert_equal(false, resp.finished?) resp._finish assert_equal(true, resp.finished?) @@ -34,6 +35,7 @@ def test_headers def test_body resp = Response.new + resp._headers({}) resp._chunk("a") resp._chunk("b") resp._finish @@ -42,6 +44,7 @@ def test_body def test_body_not_finished resp = Response.new + resp._headers({}) resp._chunk("a") resp._chunk("b") assert_raises { # TODO @@ -51,6 +54,7 @@ def test_body_not_finished def test_on_chunk resp = Response.new + resp._headers({}) res = [] resp._chunk("a") resp._chunk("b") @@ -63,6 +67,7 @@ def test_on_chunk def test_on_finish resp = Response.new + resp._headers({}) ran = false resp.on_finish { ran = true } resp._finish