From ffca367149177be33744aea515a3dea02c380a4a Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Sat, 1 Aug 2026 15:13:54 +0000 Subject: [PATCH 1/2] expose redirect history on responses --- lib/wreq_ruby/response.rb | 110 +++++++++++++++++++ src/client/resp.rs | 124 +++++++++++++++++++++- test/redirect_history_test.rb | 192 ++++++++++++++++++++++++++++++++++ 3 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 test/redirect_history_test.rb diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index 8a497db..49ad2a0 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -177,6 +177,116 @@ def chunks # response.close def close end + + # Get the redirect history for this response. + # + # Returns an ordered, frozen array of {Wreq::RedirectHistoryEntry} objects + # representing each hop followed during the request. When no redirects + # were followed (including when redirects are disabled), returns an empty + # frozen array. + # + # History is available regardless of whether the response body has been + # consumed or closed. + # + # @return [Array] Ordered redirect hops (frozen) + # + # @example No redirects + # response = client.get("https://example.com/page") + # response.history # => [] + # + # @example Single redirect + # response = client.get("https://example.com/old", + # allow_redirects: true) + # response.history.length # => 1 + # hop = response.history[0] + # hop.status # => 301 + # hop.previous_url # => "https://example.com/old" + # hop.url # => "https://example.com/new" + # + # @example Iterating over multiple hops + # response.history.each do |hop| + # puts "#{hop.status}: #{hop.previous_url} -> #{hop.url}" + # end + # + # @example Converting to hashes + # response.history.map(&:to_h) + def history + end + end + + # A single hop in the redirect history of a response. + # + # Each entry captures the status code, source and destination URLs, + # and headers from one intermediate redirect response. Entries are + # immutable value objects. + # + # Sensitive URL components (query strings, userinfo) are redacted + # from {#inspect} and {#to_s} output. + # + # @see Response#history + class RedirectHistoryEntry + # The HTTP status code of the redirect response. + # + # @return [Integer] Status code (e.g., 301, 302, 307, 308) + # @example + # hop.status # => 301 + def status + end + + # The resolved destination URL of the redirect. + # + # @return [String] The URL that was redirected to + # @example + # hop.url # => "https://example.com/new-page" + def url + end + + # The URL that was requested before this redirect occurred. + # + # @return [String] The source URL of the redirect + # @example + # hop.previous_url # => "https://example.com/old-page" + def previous_url + end + + # The headers from the redirect response. + # + # Returns a mutable snapshot of the intermediate response headers. + # Duplicate header values are preserved. + # + # @return [Wreq::Headers] Headers from the redirect response + # @example + # hop.headers.get("location") # => "https://example.com/new" + def headers + end + + # Convert this entry to a Hash with symbol keys. + # + # @return [Hash{Symbol => Object}] Hash with +:status+, +:url+, + # +:previous_url+, and +:headers+ keys + # @example + # hop.to_h + # # => { status: 301, url: "...", previous_url: "...", + # # headers: # } + def to_h + end + + # Return a compact, safe string representation. + # + # Query strings and userinfo are redacted from URLs. + # + # @return [String] Formatted entry for debugging + # @example + # hop.inspect + # # => "# https://example.com/new>" + def inspect + end + + # Return the same representation as {#inspect}. + # + # @return [String] + def to_s + end end end end diff --git a/src/client/resp.rs b/src/client/resp.rs index 9cc2f98..01c235c 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,8 +5,11 @@ use bytes::Bytes; use futures_util::TryFutureExt; use http::{Extensions, HeaderMap, response::Response as HttpResponse}; use http_body_util::BodyExt; -use magnus::{Error, Module, RArray, RModule, Ruby, Value, scan_args::scan_args}; +use magnus::{ + Error, Module, RArray, RHash, RModule, Ruby, Value, scan_args::scan_args, value::ReprValue, +}; use wreq::Uri; +use wreq::redirect::History as WreqHistory; use crate::{ arch::ProcessLocal, @@ -46,6 +49,80 @@ struct NativeResponseState { extensions: Extensions, } +/// A single redirect hop extracted from the response's redirect history. +#[magnus::wrap(class = "Wreq::RedirectHistoryEntry", free_immediately, size)] +struct RedirectHistoryEntry { + status: u16, + url: String, + previous_url: String, + headers: HeaderMap, +} + +impl RedirectHistoryEntry { + fn status(&self) -> u16 { + self.status + } + + fn url(&self) -> &str { + &self.url + } + + fn previous_url(&self) -> &str { + &self.previous_url + } + + fn headers(&self) -> Headers { + Headers::from(self.headers.clone()) + } + + fn to_h(ruby: &Ruby, rb_self: &Self) -> RHash { + let hash = ruby.hash_new(); + let _ = hash.aset(ruby.to_symbol("status"), rb_self.status); + let _ = hash.aset(ruby.to_symbol("url"), rb_self.url.as_str()); + let _ = hash.aset( + ruby.to_symbol("previous_url"), + rb_self.previous_url.as_str(), + ); + let _ = hash.aset(ruby.to_symbol("headers"), rb_self.headers()); + hash + } + + fn inspect(&self) -> String { + format!( + "# {}>", + self.status, + redact_url(&self.previous_url), + redact_url(&self.url) + ) + } +} + +/// Redact query string and userinfo from a URL for safe display. +fn redact_url(url: &str) -> String { + match Uri::try_from(url) { + Ok(uri) => { + let mut result = String::new(); + if let Some(scheme) = uri.scheme_str() { + result.push_str(scheme); + result.push_str("://"); + } + if let Some(host) = uri.host() { + result.push_str(host); + } + if let Some(port) = uri.port() { + result.push(':'); + result.push_str(&port.to_string()); + } + result.push_str(uri.path()); + if uri.query().is_some() { + result.push_str("?[REDACTED]"); + } + result + } + Err(_) => "[invalid URI]".to_owned(), + } +} + impl Response { /// Create a new [`Response`] instance. pub fn new(response: wreq::Response) -> Self { @@ -168,6 +245,38 @@ impl Response { Headers::from(self.headers.clone()) } + /// Get the redirect history as a frozen array of RedirectHistoryEntry values. + fn history(ruby: &Ruby, rb_self: &Self) -> RArray { + let state = rb_self.state.as_ref(); + let entries = state.extensions.get::(); + + match entries { + Some(history) => { + let items: Vec = history + .into_iter() + .map(|entry| RedirectHistoryEntry { + status: entry.status.as_u16(), + url: entry.uri.to_string(), + previous_url: entry.previous.to_string(), + headers: entry.headers.clone(), + }) + .collect(); + + let ary = ruby.ary_new_capa(items.len()); + for item in items { + let _ = ary.push(item); + } + let _: Result = ary.funcall("freeze", ()); + ary + } + None => { + let ary = ruby.ary_new(); + let _: Result = ary.funcall("freeze", ()); + ary + } + } + } + /// Get the local socket address, if available. #[inline] pub fn local_addr(&self) -> Option { @@ -258,5 +367,18 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { response.define_method("json", magnus::method!(Response::json, 0))?; response.define_method("chunks", magnus::method!(Response::chunks, 0))?; response.define_method("close", magnus::method!(Response::close, 0))?; + response.define_method("history", magnus::method!(Response::history, 0))?; + + let entry_class = gem_module.define_class("RedirectHistoryEntry", ruby.class_object())?; + entry_class.define_method("status", magnus::method!(RedirectHistoryEntry::status, 0))?; + entry_class.define_method("url", magnus::method!(RedirectHistoryEntry::url, 0))?; + entry_class.define_method( + "previous_url", + magnus::method!(RedirectHistoryEntry::previous_url, 0), + )?; + entry_class.define_method("headers", magnus::method!(RedirectHistoryEntry::headers, 0))?; + entry_class.define_method("to_h", magnus::method!(RedirectHistoryEntry::to_h, 0))?; + entry_class.define_method("inspect", magnus::method!(RedirectHistoryEntry::inspect, 0))?; + entry_class.define_method("to_s", magnus::method!(RedirectHistoryEntry::inspect, 0))?; Ok(()) } diff --git a/test/redirect_history_test.rb b/test/redirect_history_test.rb new file mode 100644 index 0000000..25cc2ea --- /dev/null +++ b/test/redirect_history_test.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "test_helper" +require "cgi" + +class RedirectHistoryTest < Minitest::Test + def setup + @client = Wreq::Client.new(allow_redirects: true, max_redirects: 10, timeout: 10) + end + + # ================================================================= + # No-redirect responses return an empty array + # ================================================================= + + def test_no_redirect_returns_empty_array + resp = @client.get("#{HTTPBIN_URL}/get") + assert_equal [], resp.history + end + + def test_no_redirect_array_is_frozen + resp = @client.get("#{HTTPBIN_URL}/get") + assert resp.history.frozen? + end + + # ================================================================= + # Redirects disabled returns empty history + # ================================================================= + + def test_redirects_disabled_returns_empty_history + client = Wreq::Client.new(allow_redirects: false, timeout: 10) + resp = client.get("#{HTTPBIN_URL}/redirect/1") + assert_equal [], resp.history + assert_equal 302, resp.code + end + + # ================================================================= + # Single hop redirect + # ================================================================= + + def test_single_redirect_has_one_entry + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + assert_equal 1, resp.history.length + end + + def test_single_redirect_entry_fields + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + hop = resp.history[0] + + assert_equal 302, hop.status + assert_includes hop.previous_url, "/redirect/1" + assert_includes hop.url, "/get" + end + + def test_single_redirect_final_url + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + assert_includes resp.url, "/get" + end + + # ================================================================= + # Multiple hops + # ================================================================= + + def test_multiple_hops_count + resp = @client.get("#{HTTPBIN_URL}/redirect/3") + assert_equal 3, resp.history.length + end + + def test_multiple_hops_are_ordered + resp = @client.get("#{HTTPBIN_URL}/redirect/3") + urls = resp.history.map(&:previous_url) + assert_includes urls[0], "/redirect/3" + assert_includes urls[1], "redirect/2" + assert_includes urls[2], "redirect/1" + end + + # ================================================================= + # History array is immutable + # ================================================================= + + def test_history_array_is_frozen + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + assert resp.history.frozen? + end + + def test_history_array_rejects_mutation + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + assert_raises(FrozenError) { resp.history << "something" } + end + + # ================================================================= + # Entry type and methods + # ================================================================= + + def test_entry_is_redirect_history_entry + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + assert_instance_of Wreq::RedirectHistoryEntry, resp.history[0] + end + + def test_entry_headers_returns_wreq_headers + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + assert_instance_of Wreq::Headers, resp.history[0].headers + end + + # ================================================================= + # to_h serialization + # ================================================================= + + def test_entry_to_h_has_expected_keys + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + hash = resp.history[0].to_h + assert_equal [:status, :url, :previous_url, :headers].sort, hash.keys.sort + end + + def test_entry_to_h_values_match_accessors + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + hop = resp.history[0] + hash = hop.to_h + assert_equal hop.status, hash[:status] + assert_equal hop.url, hash[:url] + assert_equal hop.previous_url, hash[:previous_url] + assert_instance_of Wreq::Headers, hash[:headers] + end + + # ================================================================= + # inspect / to_s + # ================================================================= + + def test_inspect_format + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + result = resp.history[0].inspect + assert result.start_with?("#") + end + + def test_to_s_equals_inspect + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + hop = resp.history[0] + assert_equal hop.inspect, hop.to_s + end + + def test_inspect_redacts_query_params + resp = @client.get("#{HTTPBIN_URL}/redirect-to?url=#{CGI.escape("#{HTTPBIN_URL}/get")}&status_code=301") + return if resp.history.empty? + + result = resp.history[0].inspect + assert_includes result, "[REDACTED]" + refute_includes result, "status_code" + end + + # ================================================================= + # Cross-origin redirect + # ================================================================= + + def test_cross_origin_redirect + resp = @client.get("http://google.com") + return if resp.history.empty? + + hop = resp.history[0] + assert_equal 301, hop.status + assert_includes hop.previous_url, "google.com" + assert_includes hop.url, "www.google.com" + end + + # ================================================================= + # Relative redirects expose resolved destination + # ================================================================= + + def test_absolute_redirect_urls_are_resolved + resp = @client.get("#{HTTPBIN_URL}/absolute-redirect/1") + return if resp.history.empty? + + hop = resp.history[0] + assert hop.url.start_with?("http://") || hop.url.start_with?("https://"), + "Redirect URL should be absolute, got: #{hop.url}" + end + + # ================================================================= + # History survives body consumption + # ================================================================= + + def test_history_available_after_body_consumed + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + resp.text + assert_equal 1, resp.history.length + end + + def test_history_available_after_close + resp = @client.get("#{HTTPBIN_URL}/redirect/1") + resp.close + assert_equal 1, resp.history.length + end +end \ No newline at end of file From 9a61a0f96d5acd5e75c6bd496122610f2ba1ce95 Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Sat, 1 Aug 2026 15:23:58 +0000 Subject: [PATCH 2/2] fix cargo lint --- src/client/resp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index 01c235c..1dae7f9 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -111,7 +111,7 @@ fn redact_url(url: &str) -> String { } if let Some(port) = uri.port() { result.push(':'); - result.push_str(&port.to_string()); + result.push_str(port.as_ref()); } result.push_str(uri.path()); if uri.query().is_some() {