diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e9e661..b173508 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,29 @@ jobs: - uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} + + test-http-6: + runs-on: ubuntu-latest + + # We want to run on external PRs, but not on our own internal PRs as they'll be run on push event + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != 'umbrellio/ezclient' + + strategy: + fail-fast: false + matrix: + ruby: ["3.2", "3.3"] + + name: http 6 / Ruby ${{ matrix.ruby }} + + env: + BUNDLE_GEMFILE: gemfiles/http_6.gemfile + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - run: bundle exec rspec diff --git a/gemfiles/http_6.gemfile b/gemfiles/http_6.gemfile new file mode 100644 index 0000000..b9d97cf --- /dev/null +++ b/gemfiles/http_6.gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +eval_gemfile "../Gemfile" + +gem "http", "~> 6.0" diff --git a/gemfiles/http_6.gemfile.lock b/gemfiles/http_6.gemfile.lock new file mode 100644 index 0000000..be4f5e9 --- /dev/null +++ b/gemfiles/http_6.gemfile.lock @@ -0,0 +1,187 @@ +PATH + remote: .. + specs: + ezclient (1.7.2) + http (>= 4) + +GEM + remote: https://rubygems.org/ + specs: + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.2) + coderay (1.1.3) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + coveralls (0.7.2) + multi_json (~> 1.3) + rest-client (= 1.6.7) + simplecov (>= 0.7) + term-ansicolor (= 1.2.2) + thor (= 0.18.1) + crack (1.0.1) + bigdecimal + rexml + diff-lcs (1.6.2) + docile (1.4.1) + domain_name (0.6.20240107) + drb (2.2.3) + hashdiff (1.2.1) + http (6.0.3) + http-cookie (~> 1.0) + llhttp (~> 0.6.1) + http-cookie (1.1.6) + domain_name (~> 0.5) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + json (2.19.4) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + llhttp (0.6.1) + logger (1.7.0) + method_source (1.1.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0414) + minitest (6.0.5) + drb (~> 2.0) + prism (~> 1.5) + multi_json (1.20.1) + parallel (1.28.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + prism (1.9.0) + pry (0.16.0) + coderay (~> 1.1) + method_source (~> 1.0) + reline (>= 0.6.0) + public_suffix (7.0.5) + racc (1.8.1) + rack (3.2.6) + rainbow (3.1.1) + rake (13.4.2) + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rest-client (1.6.7) + mime-types (>= 1.16) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.84.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-config-umbrellio (1.84.118) + rubocop (~> 1.84.0) + rubocop-factory_bot (~> 2.28.0) + rubocop-performance (~> 1.26.0) + rubocop-rails (~> 2.34.0) + rubocop-rake (~> 0.7.0) + rubocop-rspec (~> 3.9.0) + rubocop-sequel (~> 0.4.0) + rubocop-factory_bot (2.28.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.3) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.9.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) + rubocop-sequel (0.4.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov-lcov (0.9.0) + simplecov_json_formatter (0.1.4) + term-ansicolor (1.2.2) + tins (~> 0.8) + thor (0.18.1) + tins (0.13.2) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + bundler + coveralls + ezclient! + http (~> 6.0) + pry + rake + rspec + rubocop-config-umbrellio + rubocop-rake + simplecov + simplecov-lcov + webmock + +BUNDLED WITH + 2.7.2 diff --git a/lib/ezclient.rb b/lib/ezclient.rb index 0b6f9c5..4cc6ada 100644 --- a/lib/ezclient.rb +++ b/lib/ezclient.rb @@ -3,6 +3,7 @@ require "http" require_relative "ezclient/version" +require_relative "ezclient/httprb_compatibility" require_relative "ezclient/client" require_relative "ezclient/persistent_client" require_relative "ezclient/persistent_client_registry" diff --git a/lib/ezclient/httprb_compatibility.rb b/lib/ezclient/httprb_compatibility.rb new file mode 100644 index 0000000..86ea72f --- /dev/null +++ b/lib/ezclient/httprb_compatibility.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module EzClient::HttprbCompatibility + KEYWORD_PARAMETER_TYPES = %i[key keyreq].freeze + + module_function + + def client_supports_build_request? + HTTP::Client.method_defined?(:build_request) + end + + def httprb_v6_or_later? + Gem::Version.new(HTTP::VERSION) >= Gem::Version.new("6") + end + + def build_request(client, verb, url, opts) + if client_supports_build_request? + client.build_request(verb, url, opts) + else + HTTP::Request::Builder.new(client.default_options.merge(opts)).build(verb, url) + end + end + + def basic_auth(client, opts) + if keyword_initializer?(client.method(:basic_auth)) + client.basic_auth(**opts) + else + client.basic_auth(opts) + end + end + + def redirector(opts) + if keyword_initializer?(HTTP::Redirector.instance_method(:initialize)) + HTTP::Redirector.new(**opts) + else + HTTP::Redirector.new(opts) + end + end + + def persistent_client(origin, keep_alive_timeout) + if client_supports_build_request? + HTTP.persistent(origin, timeout: keep_alive_timeout) + else + HTTP::Client.new(persistent: origin, keep_alive_timeout: keep_alive_timeout) + end + end + + def response(**attrs) + if keyword_initializer?(HTTP::Response.instance_method(:initialize)) + HTTP::Response.new(**attrs) + else + HTTP::Response.new(attrs) + end + end + + def keyword_initializer?(method) + method.parameters.any? { |type, _name| KEYWORD_PARAMETER_TYPES.include?(type) } + end +end diff --git a/lib/ezclient/persistent_client.rb b/lib/ezclient/persistent_client.rb index 9f9fd1d..4ddfb49 100644 --- a/lib/ezclient/persistent_client.rb +++ b/lib/ezclient/persistent_client.rb @@ -3,7 +3,7 @@ class EzClient::PersistentClient extend Forwardable - def_delegators :http_client, :build_request, :default_options, :timeout + def_delegators :http_client, :basic_auth, :build_request, :cookies, :default_options, :timeout def initialize(origin, keep_alive_timeout) self.origin = origin @@ -26,6 +26,6 @@ def timed_out? attr_accessor :origin, :keep_alive_timeout, :last_request_at def http_client - @http_client ||= HTTP.persistent(origin, timeout: keep_alive_timeout) + @http_client ||= EzClient::HttprbCompatibility.persistent_client(origin, keep_alive_timeout) end end diff --git a/lib/ezclient/request.rb b/lib/ezclient/request.rb index a298d91..16f463a 100644 --- a/lib/ezclient/request.rb +++ b/lib/ezclient/request.rb @@ -10,6 +10,51 @@ class EzClient::Request query ].freeze + class RedirectCookieState + def initialize(response) + self.cookie_jar = HTTP::CookieJar.new + self.expired_cookie_names = [] + store(response) + end + + def store(response) + response.headers.get(HTTP::Headers::SET_COOKIE).each do |set_cookie| + HTTP::Cookie.parse(set_cookie, response.request.uri).each do |cookie| + expired_cookie_names << cookie.name if cookie.expired? + cookie_jar.add(cookie) + end + end + end + + def apply_to(request) + cookies = cookie_header_for(request) + + if cookies.empty? + request.headers.delete(HTTP::Headers::COOKIE) + else + request.headers.set(HTTP::Headers::COOKIE, cookies) + end + end + + private + + attr_accessor :cookie_jar, :expired_cookie_names + + def cookie_header_for(request) + response_cookies = cookie_jar.cookies(request.uri) + excluded_names = expired_cookie_names | response_cookies.map(&:name) + + cookie_values = request_cookie_values(request, excluded_names) + cookie_values.concat(response_cookies.map(&:cookie_value)).join("; ") + end + + def request_cookie_values(request, excluded_names) + HTTP::Cookie.cookie_value_to_hash(request.headers[HTTP::Headers::COOKIE].to_s) + .except(*excluded_names) + .map { |name, value| "#{name}=#{HTTP::Cookie::Scanner.quote(value)}" } + end + end + attr_accessor :verb, :url, :options, :elapsed_seconds def initialize(verb, url, options) @@ -43,7 +88,8 @@ def perform! def api_auth!(*args) raise "ApiAuth gem is not loaded" unless defined?(ApiAuth) - ApiAuth.sign!(http_request, *args) + + ApiAuth.sign!(api_auth_request, *args) self end @@ -73,28 +119,45 @@ def http_options attr_accessor :client + def api_auth_request + http_request.tap { |request| define_api_auth_header_accessors(request) } + end + + def define_api_auth_header_accessors(request) + # api-auth 2.x expects HTTP::Request to expose header accessors that were removed in httprb 6. + request.define_singleton_method(:[]) { |key| headers[key] } unless request.respond_to?(:[]) + + return if request.respond_to?(:[]=) + + request.define_singleton_method(:[]=) { |key, value| headers[key] = value } + end + def http_request - @http_request ||= begin - opts = {} - - opts[verb == "GET" ? :params : :form] = options[:params] - opts[:json] = options[:json] if options[:json] - opts[:body] = options[:body] if options[:body] - opts[:params] = options[:query] if options[:query] - opts[:form] = options[:form] if options[:form] - opts[:form] = prepare_form_params(opts[:form]) if opts[:form] - opts[:headers] = prepare_headers(options[:headers]) - - http_client.build_request(verb, url, opts) - end + @http_request ||= EzClient::HttprbCompatibility.build_request( + http_client, + verb, + url, + build_request_opts, + ) + end + + def build_request_opts + opts = {} + opts[verb == "GET" ? :params : :form] = options[:params] if options[:params] + opts[:json] = options[:json] if options[:json] + opts[:body] = options[:body] if options[:body] + opts[:params] = options[:query] if options[:query] + opts[:form] = options[:form] if options[:form] + opts[:form] = prepare_form_params(opts[:form]) if opts[:form] + opts[:headers] = prepare_headers(options[:headers]) + opts end def http_client - # Only used to build proper HTTP::Request and HTTP::Options instances @http_client ||= begin http_client = client.dup http_client = set_timeout(http_client) - http_client = http_client.basic_auth(basic_auth) if basic_auth + http_client = EzClient::HttprbCompatibility.basic_auth(http_client, basic_auth) if basic_auth http_client = http_client.cookies(options[:cookies]) if options[:cookies] http_client end @@ -107,14 +170,41 @@ def perform_request res = client.perform(http_request, http_options) return res unless follow - HTTP::Redirector.new(follow).perform(http_request, res) do |request| - client.perform(request, http_options) - end + perform_redirects(res) end ensure self.elapsed_seconds = EzClient.get_time - perform_started_at end + def perform_redirects(response) + if EzClient::HttprbCompatibility.client_supports_build_request? + redirector(follow).perform(http_request, response) { |req| client.perform(req, http_options) } + else + perform_redirects_with_cookies(response) + end + end + + def perform_redirects_with_cookies(response) + cookie_state = RedirectCookieState.new(response) + redirect_opts = follow.dup + on_redirect = redirect_opts.delete(:on_redirect) + redirect_response = response + + redirector(redirect_opts).perform(http_request, response) do |req| + cookie_state.apply_to(req) + on_redirect&.call(redirect_response, req) + + client.perform(req, http_options).tap do |res| + cookie_state.store(res) + redirect_response = res + end + end + end + + def redirector(options) + EzClient::HttprbCompatibility.redirector(options) + end + def with_retry(&block) retries = 0 @@ -187,7 +277,6 @@ def prepare_headers(headers) def prepare_form_params(original_params) params = {} - # NOTE: use Hash#transform_values after Ruby 2.3 support is dropped original_params.each do |key, value| params[key] = if value.is_a?(File) diff --git a/spec/ezclient_spec.rb b/spec/ezclient_spec.rb index 3fc9ee9..17f3c20 100644 --- a/spec/ezclient_spec.rb +++ b/spec/ezclient_spec.rb @@ -131,9 +131,8 @@ def self.sign!(*); end request.perform expect(webmock_requests.last.body).to eq('{"a":1}') - expect(webmock_requests.last.headers).to include( - "Content-Type" => "application/json; charset=utf-8", - ) + expect(webmock_requests.last.headers["Content-Type"].downcase) + .to eq("application/json; charset=utf-8") end end @@ -240,6 +239,19 @@ def self.sign!(*); end response = request.perform! expect(response.body).to eq("some body") end + + context "when basic_auth and cookies are provided" do + let(:request_options) { { basic_auth: %w[user password], cookies: { a: 1 } } } + + it "uses them while building a persistent request" do + request.perform + + expect(webmock_requests.last.headers).to include( + "Authorization" => "Basic dXNlcjpwYXNzd29yZA==", + "Cookie" => "a=1", + ) + end + end end end @@ -370,6 +382,30 @@ def self.sign!(*); end expect(request.headers).to include("Authorization" => "some-hash-here") end + + context "when HTTP::Request does not expose header accessors" do + let(:client_options) { {} } + let(:http_request) { Struct.new(:headers).new(HTTP::Headers.new) } + + before do + allow(http_request).to receive(:respond_to?).and_call_original + allow(http_request).to receive(:respond_to?).with(:[]).and_return(false) + allow(http_request).to receive(:respond_to?).with(:[]=).and_return(false) + allow(request).to receive(:http_request).and_return(http_request) + end + + it "adds api-auth-compatible header accessors" do + expect(ApiAuth).to receive(:sign!) do |signed_request, access_id, access_key| + expect(access_id).to eq("id") + expect(access_key).to eq("secret") + signed_request["Authorization"] = "some-hash-here" + end + + request.api_auth!("id", "secret") + + expect(http_request.headers.to_h).to include("Authorization" => "some-hash-here") + end + end end context "when unknown client option is passed" do @@ -396,12 +432,14 @@ def self.sign!(*); end context "object inspectation" do specify "#inspect" do - expect(response.inspect.gsub(/0x\w+/, "0x0000")).to eq(<<~TXT.gsub(/\s+/, " ").strip) - #, - @http_request=#, - @body=""> - TXT + inspected = response.inspect.gsub(/0x\w+/, "0x0000") + # HTTP::Response#inspect format changed between httprb v5 and v6: + # v5: "#" (shows headers as {}) + # v6: "#" (shows mime_type, nil = empty) + expect(inspected).to include("#") + expect(inspected).to include('@body="">') end specify "#to_s" do @@ -548,4 +586,322 @@ def self.sign!(*); end expect(request.headers).to include("Authorization" => "Basic dXNlcjpwYXNzd29yZA==") end end + + # The following contexts exercise httprb v6-specific code paths by stubbing + # HTTP::Client#build_request as unsupported, ensuring coverage even when running under httprb v5. + context "when HTTP::Client#build_request is unsupported" do + before do + allow(EzClient::HttprbCompatibility) + .to receive(:client_supports_build_request?) + .and_return(false) + + unless defined?(HTTP::Request::Builder) + stub_const("HTTP::Request::Builder", Class.new do + def initialize(opts) + @opts = opts + end + + def build(verb, url) + HTTP::Client.new.build_request(verb, url, @opts) + end + end) + end + end + + context "when making a basic request" do + before { request_stub.to_return(body: "v6 response") } + + it "performs request using HTTP::Request::Builder" do + response = request.perform + expect(response.body).to eq("v6 response") + end + end + + context "when basic_auth request option is provided" do + let(:request_options) { { basic_auth: { user: "user", pass: "password" } } } + + it "sets Authorization header using keyword args (v6 path)" do + expect(request.headers).to include("Authorization" => "Basic dXNlcjpwYXNzd29yZA==") + end + end + + context "when follow redirect" do + before do + request_stub.to_return(status: 302, headers: { "Location" => "http://redirect.me" }) + end + + let(:verb) { :get } + let(:request_options) { { follow: true } } + + before do + stub_request(:get, /redirect\.me/) + .with { |req| webmock_requests << req } + .to_return(body: "redirected") + end + + it "follows redirect using HTTP::Redirector with keyword args" do + request.perform + expect(webmock_requests.size).to eq(2) + end + end + + context "when followed redirect sets cookies" do + before do + request_stub.to_return( + status: 302, + headers: { "Location" => "http://example.com/redirected", "Set-Cookie" => "sid=1" }, + ) + + stub_request(:get, "http://example.com/redirected") + .with { |req| webmock_requests << req } + .to_return(body: "redirected") + end + + let(:verb) { :get } + let(:request_options) { { follow: true } } + + it "sends response cookies to the next request" do + request.perform + expect(webmock_requests.last.headers).to include("Cookie" => "sid=1") + end + end + + context "when follow redirect has on_redirect callback" do + let(:verb) { :get } + let(:calls) { [] } + let(:request_options) { { follow: { on_redirect: on_redirect } } } + + let(:on_redirect) do + proc do |response, redirect_request| + calls << [response.code, redirect_request.headers[HTTP::Headers::COOKIE].to_s] + end + end + + before do + request_stub.to_return( + status: 302, + headers: { "Location" => "http://example.com/redirected", "Set-Cookie" => "sid=1" }, + ) + + stub_request(:get, "http://example.com/redirected") + .with { |req| webmock_requests << req } + .to_return(body: "redirected") + end + + it "calls it with the response and redirected request after applying cookies" do + request.perform + expect(calls).to eq([[302, "sid=1"]]) + end + end + + context "when redirected request has cookies" do + before do + request_stub.to_return( + status: 302, + headers: { "Location" => "http://example.com/redirected" }, + ) + + stub_request(:get, "http://example.com/redirected") + .with { |req| webmock_requests << req } + .to_return(body: "redirected") + end + + let(:verb) { :get } + let(:request_options) { { cookies: { sid: 1 }, follow: true } } + + it "sends original request cookies to the next request" do + request.perform + expect(webmock_requests.last.headers).to include("Cookie" => "sid=1") + end + end + + context "when redirect response sets an empty cookie" do + before do + request_stub.to_return( + status: 302, + headers: { + "Location" => "http://example.com/redirected", + "Set-Cookie" => "sid=; Path=/", + }, + ) + + stub_request(:get, "http://example.com/redirected") + .with { |req| webmock_requests << req } + .to_return(body: "redirected") + end + + let(:verb) { :get } + let(:request_options) { { follow: true } } + + it "preserves it as a legitimate cookie value" do + request.perform + expect(webmock_requests.last.headers).to include("Cookie" => "sid=") + end + end + + context "when redirect response expires cookies" do + before do + request_stub.to_return( + status: 302, + headers: { + "Location" => "http://example.com/redirected", + "Set-Cookie" => "sid=; Max-Age=0; Path=/", + }, + ) + + stub_request(:get, "http://example.com/redirected") + .with { |req| webmock_requests << req } + .to_return(body: "redirected") + end + + let(:verb) { :get } + let(:request_options) { { cookies: { sid: 1 }, follow: true } } + + it "removes expired cookies from the next request" do + request.perform + expect(webmock_requests.last.headers).not_to include("Cookie") + end + end + end +end + +RSpec.describe EzClient::Request::RedirectCookieState do + let(:cookies) { { sid: "a;b" } } + + let(:ezclient_request) do + EzClient.new.request(:get, "http://example.com", cookies: cookies) + end + + let(:http_request) { ezclient_request.send(:http_request) } + let(:redirect_request) { http_request.redirect("http://example.com/redirected") } + let(:response) { Struct.new(:headers, :request).new(HTTP::Headers.coerce({}), http_request) } + + it "preserves request cookie values that require quoting" do + described_class.new(response).apply_to(redirect_request) + + expect(redirect_request.headers[HTTP::Headers::COOKIE].to_s).to eq('sid="a;b"') + end + + context "when request has a full Cookie header string" do + let(:cookies) { {} } + + before do + http_request.headers[HTTP::Headers::COOKIE] = 'sid="a;b"; path=/' + end + + it "round-trips every parsed cookie pair to the redirect request" do + described_class.new(response).apply_to(redirect_request) + + expect(redirect_request.headers[HTTP::Headers::COOKIE].to_s).to eq('sid="a;b"; path=/') + end + end +end + +RSpec.describe EzClient::HttprbCompatibility do + context "when basic auth expects keyword arguments" do + let(:client_class) do + Class.new do + attr_reader :credentials + + def basic_auth(user:, pass:) + @credentials = { user: user, pass: pass } + self + end + end + end + + let(:client) { client_class.new } + + it "passes credentials as keyword arguments" do + expect(described_class.basic_auth(client, { user: "user", pass: "password" })).to eq(client) + expect(client.credentials).to eq(user: "user", pass: "password") + end + end + + context "when redirector expects keyword arguments" do + let(:redirector_class) do + Class.new do + attr_reader :options + + def initialize(max_hops:) + @options = { max_hops: max_hops } + end + end + end + + before do + stub_const("HTTP::Redirector", redirector_class) + end + + it "passes options as keyword arguments" do + expect(described_class.redirector(max_hops: 3).options).to eq(max_hops: 3) + end + end + + context "when response expects keyword arguments" do + let(:response_class) do + Class.new do + attr_reader :attributes + + def initialize(status:, headers:) + @attributes = { status: status, headers: headers } + end + end + end + + before do + stub_const("HTTP::Response", response_class) + end + + it "passes attributes as keyword arguments" do + expect(described_class.response(status: 200, headers: {}).attributes) + .to eq(status: 200, headers: {}) + end + end + + context "when response expects positional hash" do + let(:response_class) do + Class.new do + attr_reader :attributes + + def initialize(attributes) + @attributes = attributes + end + end + end + + before do + stub_const("HTTP::Response", response_class) + end + + it "passes attributes as a positional hash" do + expect(described_class.response(status: 200, headers: {}).attributes) + .to eq(status: 200, headers: {}) + end + end +end + +RSpec.describe EzClient::PersistentClient do + # Exercises the httprb v6-specific code path in http_client by stubbing + # build_request as unsupported. + context "when HTTP::Client#build_request is unsupported" do + before do + allow(EzClient::HttprbCompatibility) + .to receive(:client_supports_build_request?) + .and_return(false) + end + + it "creates HTTP::Client with persistent connection options" do + mock_client = double("HTTP::Client") + allow(HTTP::Client).to receive(:new) + .with(persistent: "http://example.com", keep_alive_timeout: 5) + .and_return(mock_client) + + client = EzClient::PersistentClient.new("http://example.com", 5) + client.send(:http_client) + + expect(HTTP::Client).to have_received(:new) + .with(persistent: "http://example.com", keep_alive_timeout: 5) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c7ba115..4b85fb5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -19,6 +19,53 @@ require "webmock/rspec" require "ezclient" +# WebMock (up to at least 3.24.0) has two incompatibilities with httprb v6. +# Remove after upgrading to a WebMock version that includes: +# https://github.com/bblimke/webmock/pull/1123 +# +# 1. HTTP::Response.new changed from accepting a positional Hash to keyword arguments. +# WebMock calls `new({status: ..., version: ..., ...})` which raises ArgumentError in v6. +# +# 2. HTTP::Response::Body#read_contents (and #readpartial) expect the underlying stream's +# #readpartial to raise EOFError at end-of-stream (per the v6 IO#readpartial contract), +# but WebMock's Streamer returns nil, causing TypeError: no implicit conversion of nil +# into String. +if EzClient::HttprbCompatibility.httprb_v6_or_later? + module HTTP + class Response + class << self + def from_webmock(request, webmock_response, _request_signature = nil) + status = Status.new(webmock_response.status.first) + headers = webmock_response.headers || {} + body = build_http_rb_response_body_from_webmock_response(webmock_response) + + EzClient::HttprbCompatibility.response( + status: status, + version: "1.1", + headers: headers, + body: body, + request: request, + ) + end + end + end + + class Response + class Streamer + # httprb v6 requires readpartial to raise EOFError at end-of-stream instead of returning nil + def readpartial(size = nil, outbuf = nil) + raise EOFError, "end of stream reached" if @io.eof? + + chunk = size ? @io.read(size, outbuf) : @io.read + raise EOFError, "end of stream reached" if chunk.nil? + + chunk.force_encoding(@encoding) + end + end + end + end +end + RSpec.configure do |config| config.order = :random Kernel.srand config.seed