Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions lib/wreq_ruby/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Wreq::RedirectHistoryEntry>] 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: #<Wreq::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
# # => "#<Wreq::RedirectHistoryEntry 301 https://example.com/old -> https://example.com/new>"
def inspect
end

# Return the same representation as {#inspect}.
#
# @return [String]
def to_s
end
end
end
end
Expand Down
124 changes: 123 additions & 1 deletion src/client/resp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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!(
"#<Wreq::RedirectHistoryEntry {} {} -> {}>",
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.as_ref());
}
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 {
Expand Down Expand Up @@ -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::<WreqHistory>();

match entries {
Some(history) => {
let items: Vec<RedirectHistoryEntry> = 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<Value, Error> = ary.funcall("freeze", ());
ary
}
None => {
let ary = ruby.ary_new();
let _: Result<Value, Error> = ary.funcall("freeze", ());
ary
}
}
}

/// Get the local socket address, if available.
#[inline]
pub fn local_addr(&self) -> Option<String> {
Expand Down Expand Up @@ -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(())
}
Loading