From 3879233b2b71f430c87b63ea035a96486ed659a9 Mon Sep 17 00:00:00 2001 From: gngpp Date: Sat, 11 Jul 2026 08:30:23 +0800 Subject: [PATCH] feat(header): make headers an idiomatic Ruby collection --- lib/wreq_ruby/header.rb | 319 ++++++++++++++++++----------- lib/wreq_ruby/response.rb | 4 +- src/header.rs | 331 ++++++++++++++++++------------ src/header/helper.rs | 112 ++++++++++ test/header_test.rb | 420 +++++++++++++++++++++----------------- 5 files changed, 746 insertions(+), 440 deletions(-) create mode 100644 src/header/helper.rs diff --git a/lib/wreq_ruby/header.rb b/lib/wreq_ruby/header.rb index b6ce5c8..6dd1b7d 100644 --- a/lib/wreq_ruby/header.rb +++ b/lib/wreq_ruby/header.rb @@ -2,202 +2,293 @@ unless defined?(Wreq) module Wreq - # HTTP headers collection. + # A mutable, case-insensitive collection of HTTP headers. # - # Provides efficient access to HTTP headers with case-insensitive lookups. - # Headers are created by the native extension and cannot be directly instantiated. + # Header names are normalized by the native header map and lookups are + # case-insensitive. Use the `orig_headers` request option when exact wire + # casing or header order is required. Duplicate values are stored as + # separate header occurrences. # - # All header names are case-insensitive. Multiple values for the same header - # are supported through the `get_all` method. + # @example Build and query headers + # headers = Wreq::Headers.new( + # "Accept" => ["application/json", "text/plain"], + # content_type: "application/json" + # ) + # headers["accept"] # => ["application/json", "text/plain"] + # headers[:content_type] # => "application/json" # - # @example Accessing response headers - # response = Wreq.get("https://example.com") - # headers = response.headers - # content_type = headers["Content-Type"] - # content_type = headers.get("content-type") # Same, case-insensitive - # - # @example Getting all values for a header - # accept_values = headers.get_all("Accept") - # # => ["application/json", "text/html"] - # - # @example Modifying headers - # headers.set("X-Custom-Header", "value") - # headers["Authorization"] = "Bearer token" - # headers.append("Accept", "application/xml") - # - # @example Iterating headers + # @example Iterate over every occurrence # headers.each do |name, value| # puts "#{name}: #{value}" # end - # - # @example Converting to hash - # hash = headers.to_h - # hash["content-type"] # => "text/html" class Headers - # Create a new empty Headers collection. + include Enumerable + + # Create an empty collection or copy header pairs from a source. # - # @return [Wreq::Headers] New headers instance + # @param source [Hash, Wreq::Headers, Enumerable] A hash, another + # headers collection, or an enumerable that yields name-value pairs. + # Omit this argument to create an empty collection. + # @return [Wreq::Headers] + # @raise [Wreq::BuilderError] if the source does not contain valid pairs # @example - # headers = Wreq::Headers.new - # headers.set("Content-Type", "application/json") - def self.new + # Wreq::Headers.new + # Wreq::Headers.new("Accept" => "application/json") + # Wreq::Headers.new([[:content_type, "application/json"]]) + def self.new(*args) end - # Get a header value by name (case-insensitive). + # Return the first value for a header. # - # Returns the first value if multiple values exist for the same header. - # - # @param name [String] Header name (case-insensitive) - # @return [String, nil] Header value, or nil if not found + # @param name [String, Symbol] Header name + # @return [String, nil] The first value, or nil when the name is missing # @example - # headers.get("Content-Type") # => "application/json" - # headers.get("content-type") # => "application/json" (same) - # headers.get("X-Nonexistent") # => nil + # headers.get("content-type") # => "application/json" + # headers.get(:missing) # => nil def get(name) end - # Get all values for a header name (case-insensitive). + # Return a header using collection-style value semantics. + # + # A missing name returns nil, one occurrence returns a String, and + # multiple occurrences return an Array. # - # Useful when a header can have multiple values (e.g., Accept, Set-Cookie). + # @param name [String, Symbol] Header name + # @return [String, Array, nil] + # @example + # headers["accept"] # => "application/json" + # headers["set-cookie"] # => ["a=1", "b=2"] + def [](name) + end + + # Return every value for a header. # - # @param name [String] Header name (case-insensitive) - # @return [Array] All values for this header (empty array if not found) + # @param name [String, Symbol] Header name + # @return [Array] Values in insertion order, or an empty array # @example - # headers.get_all("Accept") - # # => ["application/json", "text/html", "application/xml"] - # headers.get_all("X-Nonexistent") # => [] + # headers.get_all("set-cookie") # => ["a=1", "b=2"] + # headers.get_all(:missing) # => [] def get_all(name) end - # Set a header value, replacing any existing values. + # Set one or more values, replacing every existing occurrence. + # + # Array values are stored as separate occurrences and are not joined. An + # empty Array removes the header. # - # @param name [String] Header name - # @param value [String] Header value + # @param name [String, Symbol] Header name + # @param value [String, Array] Header value or values # @return [void] - # @raise [Wreq::BuilderError] if name or value contains invalid characters + # @raise [Wreq::BuilderError] if a name or value is invalid # @example - # headers.set("Content-Type", "application/json") - # headers.set("X-Custom-Header", "my-value") + # headers.set("Accept", ["application/json", "text/plain"]) def set(name, value) end - # Append a header value without replacing existing values. + # Set one or more values and return the assigned value. # - # Adds a new value for the header, preserving any existing values. - # Useful for headers that can have multiple values. + # @param name [String, Symbol] Header name + # @param value [String, Array] Header value or values + # @return [String, Array] The assigned value + # @example + # headers[:content_type] = "application/json" + def []=(name, value) + end + + # Append one or more values without replacing existing occurrences. # - # @param name [String] Header name - # @param value [String] Header value to append + # @param name [String, Symbol] Header name + # @param value [String, Array] Header value or values # @return [void] - # @raise [Wreq::BuilderError] if name or value contains invalid characters + # @raise [Wreq::BuilderError] if a name or value is invalid # @example - # headers.set("Accept", "application/json") - # headers.append("Accept", "text/html") - # headers.get_all("Accept") # => ["application/json", "text/html"] + # headers.append("Set-Cookie", ["a=1", "b=2"]) def append(name, value) end - # Remove all values for a header name. + # Return a header value, a fallback, or the result of a block. # - # @param name [String] Header name (case-insensitive) - # @return [String, nil] The removed value (first one if multiple), or nil + # @param name [String, Symbol] Header name + # @param default [Object] Optional fallback for a missing name + # @yieldparam name [String, Symbol] The missing name + # @return [String, Array, Object] + # @raise [KeyError] if the name is missing and no fallback is provided # @example - # headers.remove("Authorization") # => "Bearer token" - # headers.remove("X-Nonexistent") # => nil - def remove(name) + # headers.fetch("accept", "*/*") + # headers.fetch(:missing) { |name| "missing: #{name}" } + def fetch(name, default = nil) end - # Check if a header exists (case-insensitive). + # Remove every occurrence for a header. # - # @param name [String] Header name - # @return [Boolean] true if the header exists + # @param name [String, Symbol] Header name + # @return [String, nil] The first removed value, or nil when missing # @example - # headers.contains?("Content-Type") # => true - # headers.contains?("X-Missing") # => false + # headers.remove("authorization") # => "Bearer token" + def remove(name) + end + + # Remove every occurrence for a header. Alias for {#remove}. + # + # @param name [String, Symbol] Header name + # @return [String, nil] The first removed value, or nil when missing + def delete(name) + end + + # Check whether a header exists. + # + # @param name [String, Symbol] Header name + # @return [Boolean] def contains?(name) end - # Check if a header key exists (alias for {#contains?}). + # Check whether a header exists. Alias for {#contains?}. # - # @param name [String] Header name - # @return [Boolean] true if the header exists - # @example - # headers.key?("Accept") # => true + # @param name [String, Symbol] Header name + # @return [Boolean] def key?(name) end - # Get the number of headers. + # Return the number of header occurrences. # - # @return [Integer] Total number of unique header names - # @example - # headers.length # => 12 + # This can be greater than `keys.length` when a name has multiple values. + # + # @return [Integer] def length end - # Check if there are no headers. + # Return the number of header occurrences. Alias for {#length}. # - # @return [Boolean] true if no headers exist - # @example - # headers.empty? # => false + # @return [Integer] + def size + end + + # Check whether the collection has no header occurrences. + # + # @return [Boolean] def empty? end - # Remove all headers. + # Remove every header occurrence. # - # @return [void] - # @example - # headers.clear - # headers.empty? # => true + # @return [Wreq::Headers] self def clear end - # Get all header names. + # Return each unique header name. # - # @return [Array] Array of header names (lowercase) - # @example - # headers.keys - # # => ["content-type", "accept", "user-agent", "authorization"] + # @return [Array] def keys end - # Get all header values. - # - # Returns one value per header (the first if multiple values exist). + # Return every header value. # - # @return [Array] Array of header values - # @example - # headers.values - # # => ["application/json", "text/html", "Mozilla/5.0", "Bearer token"] + # @return [Array] def values end - # Iterate over headers. - # - # Yields each header name and value pair. If a header has multiple values, - # only the first is yielded. + # Iterate over every header occurrence. # - # @yieldparam name [String] Header name (lowercase) + # @yieldparam name [String] Normalized lowercase header name # @yieldparam value [String] Header value - # @return [Enumerator, self] Returns enumerator if no block given, self otherwise - # @example With block - # headers.each do |name, value| - # puts "#{name}: #{value}" - # end - # @example Without block - # enum = headers.each - # enum.to_a # => [["content-type", "text/html"], ...] + # @return [Enumerator, Wreq::Headers] An Enumerator without a block, + # otherwise self + # @example + # headers.each.to_a def each end + # Convert every occurrence to name-value pairs. + # + # @return [Array] + # @example + # headers.to_a # => [["accept", "application/json"], ...] + def to_a + end + + # Convert unique names to a Hash. + # + # Hash values use the same nil, String, or Array shape as {#[]}. + # + # @return [Hash{String => String, Array}] + # @example + # headers.to_h # => {"accept" => "application/json"} + def to_h + end + + # Convert unique names to a Hash. Alias for {#to_h}. + # + # @return [Hash{String => String, Array}] + def to_hash + end + # Convert headers to a string representation. + # + # @return [String] def to_s end + + # Return a compact representation for debugging. + # + # @return [String] + # @example + # headers.inspect # => "#" + def inspect + end end end end +# ======================== Ruby API Extensions ======================== + module Wreq class Headers + FETCH_UNDEFINED = Object.new.freeze + private_constant :FETCH_UNDEFINED + + alias delete remove + alias size length + + # Return a header value, a fallback, or the result of a block. + # + # The block takes precedence when both a default and block are provided. + # + # @param name [String, Symbol] Header name + # @param default [Object] Optional fallback for a missing name + # @yieldparam name [String, Symbol] The missing name + # @return [String, Array, Object] + # @raise [KeyError] if the name is missing and no fallback is provided + def fetch(name, default = FETCH_UNDEFINED) + value = self[name] + return value unless value.nil? + return yield(name) if block_given? + return default unless default.equal?(FETCH_UNDEFINED) + + raise KeyError, "key not found: #{name.inspect}" + end + + # Convert every header occurrence to a name-value pair. + # + # @return [Array] + def to_a + each.to_a + end + + # Convert unique normalized names to a Hash. + # + # A name with one occurrence maps to a String, while multiple occurrences + # map to an Array. + # + # @return [Hash{String => String, Array}] + def to_h + keys.to_h { |name| [name, self[name]] } + end + + alias to_hash to_h + + # Return a compact representation for debugging. + # + # @return [String] def inspect "#" end diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index 8edcc6f..7a40ad8 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -67,7 +67,9 @@ def content_length # Get the response headers. # # Header names are case-insensitive. Use {Wreq::Headers#get_all} to get - # every value when a header appears more than once. + # every value when a header appears more than once. Each call returns a + # fresh, mutable snapshot. Changing that snapshot does not change the + # response or a later snapshot, and object identity is not guaranteed. # # @return [Wreq::Headers] Response headers # @example diff --git a/src/header.rs b/src/header.rs index 5326e2c..1d9e982 100644 --- a/src/header.rs +++ b/src/header.rs @@ -1,161 +1,257 @@ +//! Native support for `Wreq::Headers`. +//! +//! Header names are normalized by [`HeaderMap`] and compared without regard to +//! case, as required by [RFC 9110 section 5.1]. Ruby collection adapters add +//! construction, indexing, and enumeration without changing the underlying +//! header representation. Exact wire casing and order remain the responsibility +//! of the `orig_headers` request option. +//! +//! [RFC 9110 section 5.1]: https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1 + +mod helper; + use std::cell::RefCell; use bytes::Bytes; -use http::{HeaderMap, HeaderName, HeaderValue}; +use http::{HeaderMap, HeaderValue}; use magnus::{ - Error, Module, Object, RArray, RHash, RModule, RString, Ruby, TryConvert, Value, - block::Yield, - function, method, - r_hash::ForEach, + Error, RArray, RModule, RString, Ruby, TryConvert, Value, function, method, + prelude::*, typed_data::{Inspect, Obj}, }; use wreq::header::OrigHeaderMap; -use crate::error::{ - header_name_error_to_magnus, header_value_error_to_magnus, type_value_error_to_magnus, +use crate::error::{header_value_error_to_magnus, type_value_error_to_magnus}; + +use self::helper::{ + ensure_header_count, from_source, header_count_error, parse_header_name, parse_header_values, }; -/// A wrapper for the User-Agent header value. +/// A validated User-Agent header value accepted from Ruby. pub struct UserAgent(pub HeaderValue); -/// HTTP headers collection with read and write operations. +/// Mutable HTTP headers exposed as `Wreq::Headers`. /// -/// This class wraps HTTP headers and provides convenient methods for -/// accessing, modifying, and iterating over header name-value pairs. +/// Names are stored in their normalized form and lookups are case-insensitive. +/// A name can have multiple values, each counted as a separate occurrence. #[derive(Clone, Default)] #[magnus::wrap(class = "Wreq::Headers", free_immediately, size)] pub struct Headers(pub RefCell); -/// A map from header names to their original casing as received in an HTTP message. +/// Header casing and order supplied through the `orig_headers` request option. pub struct OrigHeaders(pub OrigHeaderMap); -struct HeaderIter { - inner: http::header::IntoIter, - next_name: Option, -} - // ===== impl UserAgent ===== impl TryConvert for UserAgent { fn try_convert(value: Value) -> Result { let s = RString::try_convert(value)?; - let header_value = - HeaderValue::from_maybe_shared(s.to_bytes()).map_err(header_value_error_to_magnus)?; - Ok(Self(header_value)) + HeaderValue::from_maybe_shared(s.to_bytes()) + .map(Self) + .map_err(header_value_error_to_magnus) } } // ===== impl Headers ===== impl Headers { - /// Create a new empty Headers instance. - #[inline] - pub fn new() -> Self { - Self::from(HeaderMap::new()) + /// Return the first value for a String or Symbol header name. + /// + /// Returns `nil` when the normalized name is not present. + pub fn get(&self, name: Value) -> Result, Error> { + let name = parse_header_name(name)?; + Ok(self.0.borrow().get(name).cloned().map(Bytes::from_owner)) } - /// Get a header value by name (case-insensitive). - #[inline] - pub fn get(&self, name: String) -> Option { - self.0.borrow().get(&name).cloned().map(Bytes::from_owner) + /// Return every value for a String or Symbol header name. + /// + /// Values retain their append order. A missing name returns an empty Array. + pub fn get_all(ruby: &Ruby, rb_self: &Self, name: Value) -> Result { + let name = parse_header_name(name)?; + let headers = rb_self.0.borrow(); + let values = headers.get_all(name).iter().cloned().map(Bytes::from_owner); + Ok(ruby.ary_from_iter(values)) } - /// Get all values for a header name (case-insensitive). - #[inline] - pub fn get_all(ruby: &Ruby, rb_self: &Self, name: String) -> RArray { - ruby.ary_from_iter( - rb_self - .0 - .borrow() - .get_all(&name) - .iter() - .cloned() - .map(Bytes::from_owner), - ) - } - - /// Set a header, replacing any existing values. - pub fn set(&self, name: String, value: String) -> Result<(), Error> { - let header_name = name - .parse::() - .map_err(header_name_error_to_magnus)?; - let header_value = HeaderValue::from_maybe_shared(Bytes::from(value)) - .map_err(header_value_error_to_magnus)?; - - self.0.borrow_mut().insert(header_name, header_value); + /// Replace every value for a header name. + /// + /// A String stores one occurrence, while an Array stores each String as a + /// separate occurrence. An empty Array removes the header. + pub fn set(&self, name: Value, value: Value) -> Result<(), Error> { + let name = parse_header_name(name)?; + let values = parse_header_values(value)?; + let mut headers = self.0.borrow_mut(); + let replaced = headers.get_all(&name).iter().count(); + ensure_header_count(headers.len(), replaced, values.len())?; + + let mut values = values.into_iter(); + let Some(first) = values.next() else { + headers.remove(name); + return Ok(()); + }; + + headers + .try_insert(name.clone(), first) + .map_err(|_| header_count_error())?; + for value in values { + headers + .try_append(name.clone(), value) + .map_err(|_| header_count_error())?; + } Ok(()) } - /// Append a header value without replacing existing values. - pub fn append(&self, name: String, value: String) -> Result<(), Error> { - let header_name = name - .parse::() - .map_err(header_name_error_to_magnus)?; - let header_value = HeaderValue::from_maybe_shared(Bytes::from(value)) - .map_err(header_value_error_to_magnus)?; - - self.0.borrow_mut().append(header_name, header_value); + /// Append one or more values without replacing existing occurrences. + /// + /// Array elements are appended separately and are never comma-folded. + pub fn append(&self, name: Value, value: Value) -> Result<(), Error> { + let name = parse_header_name(name)?; + let values = parse_header_values(value)?; + let mut headers = self.0.borrow_mut(); + ensure_header_count(headers.len(), 0, values.len())?; + + for value in values { + headers + .try_append(name.clone(), value) + .map_err(|_| header_count_error())?; + } Ok(()) } - /// Remove all values for a header name. - #[inline] - pub fn remove(&self, name: String) -> Option { - self.0.borrow_mut().remove(&name).map(Bytes::from_owner) + /// Remove every value for a header name and return its first value. + /// + /// Returns `nil` when the normalized name is not present. + pub fn remove(&self, name: Value) -> Result, Error> { + let name = parse_header_name(name)?; + Ok(self.0.borrow_mut().remove(name).map(Bytes::from_owner)) } - /// Check if a header exists (case-insensitive). - #[inline] - pub fn contains(&self, name: String) -> bool { - self.0.borrow().contains_key(&name) + /// Return whether a String or Symbol header name is present. + pub fn contains(&self, name: Value) -> Result { + let name = parse_header_name(name)?; + Ok(self.0.borrow().contains_key(name)) } - /// Get the number of headers. + /// Return the total number of header occurrences. + /// + /// This can be greater than `keys.length` when names have multiple values. #[inline] pub fn len(&self) -> usize { self.0.borrow().len() } - /// Check if headers are empty. + /// Return whether the collection contains no header occurrences. #[inline] pub fn is_empty(&self) -> bool { self.0.borrow().is_empty() } - /// Clear all headers. - #[inline] - pub fn clear(&self) { - self.0.borrow_mut().clear(); - } - - /// Get all header names. - #[inline] + /// Return each unique normalized header name. pub fn keys(ruby: &Ruby, rb_self: &Self) -> RArray { ruby.ary_from_iter(rb_self.0.borrow().keys().cloned().map(Bytes::from_owner)) } - /// Get all header values. + /// Return all values, including duplicate-name occurrences. #[inline] pub fn values(ruby: &Ruby, rb_self: &Self) -> RArray { ruby.ary_from_iter(rb_self.0.borrow().values().cloned().map(Bytes::from_owner)) } - /// Iterate over headers with Ruby block support. - #[inline] - pub fn each(&self) -> Yield> { - Yield::Iter(HeaderIter { - inner: self.0.borrow().clone().into_iter(), - next_name: None, - }) - } - - /// Convert headers to string representation. + /// Return the debug representation of the underlying header map. #[inline] pub fn to_s(&self) -> String { self.0.borrow().inspect() } } +// Ruby collection adapters are kept separate from the core HeaderMap operations. +impl Headers { + /// Create an empty collection or populate it from a Ruby source. + /// + /// The optional source may be a Hash, another `Wreq::Headers`, or an + /// Enumerable whose elements are name-value pairs. + pub fn new(ruby: &Ruby, args: &[Value]) -> Result { + match args { + [] => Ok(Self::default()), + [source] => from_source(*source), + _ => Err(Error::new( + ruby.exception_arg_error(), + format!( + "wrong number of arguments (given {}, expected 0..1)", + args.len() + ), + )), + } + } + + /// Return a value using Ruby collection semantics. + /// + /// A missing name returns `nil`, one occurrence returns a String, and + /// multiple occurrences return an Array of Strings. + pub fn index(ruby: &Ruby, rb_self: &Self, name: Value) -> Result { + let name = parse_header_name(name)?; + let headers = rb_self.0.borrow(); + let all_values = headers.get_all(name); + let mut values = all_values.iter(); + + let Some(first) = values.next() else { + return Ok(ruby.qnil().as_value()); + }; + let Some(second) = values.next() else { + return Ok(ruby.into_value(Bytes::from_owner(first.clone()))); + }; + + let values = std::iter::once(first) + .chain(std::iter::once(second)) + .chain(values) + .cloned() + .map(Bytes::from_owner); + Ok(ruby.into_value(ruby.ary_from_iter(values))) + } + + /// Replace a header and return the assigned Ruby value for `headers[name] = value`. + pub fn set_index(&self, name: Value, value: Value) -> Result { + self.set(name, value)?; + Ok(value) + } + + /// Remove every occurrence and return the same `Wreq::Headers` object. + pub fn clear(rb_self: Value) -> Result { + let headers = Obj::::try_convert(rb_self)?; + headers.0.borrow_mut().clear(); + Ok(rb_self) + } + + /// Yield every normalized name-value occurrence. + /// + /// Returns an Enumerator without a block and returns the collection after + /// yielding when a block is provided. + pub fn each(ruby: &Ruby, rb_self: Value) -> Result { + if !ruby.block_given() { + return Ok(ruby.into_value(rb_self.enumeratorize("each", ()))); + } + + let headers = Obj::::try_convert(rb_self)?; + // Release the RefCell borrow before yielding because Ruby code may + // mutate this collection from inside the block. + let entries: Vec<_> = headers + .0 + .borrow() + .iter() + .map(|(name, value)| { + ( + Bytes::from_owner(name.clone()), + Bytes::from_owner(value.clone()), + ) + }) + .collect(); + for (name, value) in entries { + let _: Value = ruby.yield_values((name, value))?; + } + Ok(rb_self) + } +} + impl From for Headers { fn from(headers: HeaderMap) -> Self { Self(RefCell::new(headers)) @@ -164,32 +260,14 @@ impl From for Headers { impl TryConvert for Headers { fn try_convert(value: Value) -> Result { - if let Some(rhash) = RHash::from_value(value) { - let mut headers = HeaderMap::new(); - - rhash.foreach(|name: RString, value: RString| { - let name = HeaderName::from_bytes(&name.to_bytes()) - .map_err(header_name_error_to_magnus)?; - let value = HeaderValue::from_maybe_shared(value.to_bytes()) - .map_err(header_value_error_to_magnus)?; - headers.insert(name, value); - - Ok(ForEach::Continue) - })?; - - return Ok(Self::from(headers)); - } - - Obj::::try_convert(value) - .map(|headers| headers.0.clone()) - .map(Self) + from_source(value) } } // ===== impl OrigHeaders ===== impl TryConvert for OrigHeaders { - fn try_convert(value: magnus::Value) -> Result { + fn try_convert(value: Value) -> Result { let mut map = OrigHeaderMap::new(); let rarray = RArray::from_value(value) @@ -203,30 +281,11 @@ impl TryConvert for OrigHeaders { } } -// ===== impl HeaderIter ===== - -impl Iterator for HeaderIter { - type Item = (Bytes, Bytes); - fn next(&mut self) -> Option { - let (name, value) = self.inner.next()?; - match (&self.next_name, name) { - (Some(next_name), None) => Some(( - Bytes::from_owner(next_name.clone()), - Bytes::from_owner(value), - )), - (_, Some(name)) => { - self.next_name = Some(name.clone()); - Some((Bytes::from_owner(name), Bytes::from_owner(value))) - } - (None, None) => None, - } - } -} - +/// Register `Wreq::Headers` and its native methods with Ruby. pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { - // Define Headers class with methods let headers_class = gem_module.define_class("Headers", ruby.class_object())?; - headers_class.define_singleton_method("new", function!(Headers::new, 0))?; + + // Core bindings expose direct HeaderMap operations. headers_class.define_method("get", method!(Headers::get, 1))?; headers_class.define_method("get_all", method!(Headers::get_all, 1))?; headers_class.define_method("set", method!(Headers::set, 2))?; @@ -236,10 +295,16 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { headers_class.define_method("key?", method!(Headers::contains, 1))?; headers_class.define_method("length", method!(Headers::len, 0))?; headers_class.define_method("empty?", method!(Headers::is_empty, 0))?; - headers_class.define_method("clear", method!(Headers::clear, 0))?; headers_class.define_method("keys", method!(Headers::keys, 0))?; headers_class.define_method("values", method!(Headers::values, 0))?; - headers_class.define_method("each", method!(Headers::each, 0))?; headers_class.define_method("to_s", method!(Headers::to_s, 0))?; + + // Ruby collection bindings cover construction, indexing, and block semantics. + headers_class.include_module(ruby.module_enumerable())?; + headers_class.define_singleton_method("new", function!(Headers::new, -1))?; + headers_class.define_method("[]", method!(Headers::index, 1))?; + headers_class.define_method("[]=", method!(Headers::set_index, 2))?; + headers_class.define_method("clear", method!(Headers::clear, 0))?; + headers_class.define_method("each", method!(Headers::each, 0))?; Ok(()) } diff --git a/src/header/helper.rs b/src/header/helper.rs new file mode 100644 index 0000000..099d989 --- /dev/null +++ b/src/header/helper.rs @@ -0,0 +1,112 @@ +//! Ruby value conversion helpers for `Wreq::Headers`. + +use bytes::Bytes; +use http::{HeaderName, HeaderValue}; +use magnus::{Error, RArray, RString, Symbol, TryConvert, Value, prelude::*, typed_data::Obj}; + +use crate::error::{ + header_name_error_to_magnus, header_value_error_to_magnus, type_value_error_to_magnus, +}; + +use super::Headers; + +/// Maximum number of field-value occurrences supported by `HeaderMap`. +const MAX_HEADER_ENTRIES: usize = 1 << 15; + +/// Build a header collection from a Ruby source object. +/// +/// Accepts another `Wreq::Headers`, a Hash, or any object whose `to_a` result +/// contains two-element name-value pairs. Array values are delegated to +/// [`Headers::append`] so each value remains a separate occurrence. +pub(super) fn from_source(source: Value) -> Result { + if let Ok(headers) = Obj::::try_convert(source) { + return Ok((*headers).clone()); + } + if !source.respond_to("to_a", false)? { + return Err(type_value_error_to_magnus( + "Expected Headers, a Hash, or an enumerable of pairs", + )); + } + + let pairs: RArray = source.funcall_public("to_a", ())?; + let headers = Headers::default(); + for pair in pairs { + let pair = RArray::try_convert(pair) + .map_err(|_| type_value_error_to_magnus("Expected each header entry to be a pair"))?; + if pair.len() != 2 { + return Err(type_value_error_to_magnus( + "Expected each header entry to contain a name and value", + )); + } + + headers.append(pair.entry(0)?, pair.entry(1)?)?; + } + Ok(headers) +} + +/// Convert a Ruby String or Symbol into a normalized HTTP header name. +/// +/// Symbol underscores are changed to hyphens before [`HeaderName`] validates +/// and normalizes the name. Other Ruby types produce `Wreq::BuilderError`. +pub(super) fn parse_header_name(value: Value) -> Result { + let name = match (RString::from_value(value), Symbol::from_value(value)) { + (Some(name), _) => name.to_bytes(), + (None, Some(name)) => Bytes::from(name.name()?.replace('_', "-")), + (None, None) => { + return Err(type_value_error_to_magnus( + "Expected a String or Symbol header name", + )); + } + }; + HeaderName::from_bytes(name.as_ref()).map_err(header_name_error_to_magnus) +} + +/// Convert a Ruby String or Array of Strings into validated header values. +/// +/// Each Array element becomes one [`HeaderValue`]. An empty Array therefore +/// produces no values, allowing `set` to remove a header and `append` to do +/// nothing. +pub(super) fn parse_header_values(value: Value) -> Result, Error> { + if let Some(values) = RArray::from_value(value) { + values.into_iter().map(parse_header_value).collect() + } else { + Ok(vec![parse_header_value(value)?]) + } +} + +/// Convert one Ruby String into a validated HTTP header value. +/// +/// Invalid Ruby types and bytes rejected by [`HeaderValue`] are mapped to +/// `Wreq::BuilderError`. +fn parse_header_value(value: Value) -> Result { + let value = RString::try_convert(value) + .map_err(|_| type_value_error_to_magnus("Expected a String header value"))?; + HeaderValue::from_maybe_shared(value.to_bytes()).map_err(header_value_error_to_magnus) +} + +/// Validate the resulting number of header occurrences before a mutation. +/// +/// `current` is the collection length, `replaced` is the number of existing +/// occurrences removed by `set`, and `added` is the incoming value count. +/// Checked arithmetic prevents overflow; an invalid calculation or a result +/// above the native [`HeaderMap`](http::HeaderMap) limit returns +/// `Wreq::BuilderError` without mutating the collection. +pub(super) fn ensure_header_count( + current: usize, + replaced: usize, + added: usize, +) -> Result<(), Error> { + let count = current + .checked_sub(replaced) + .and_then(|count| count.checked_add(added)); + if count.is_some_and(|count| count <= MAX_HEADER_ENTRIES) { + Ok(()) + } else { + Err(header_count_error()) + } +} + +/// Build the error returned when the native header map reaches its entry limit. +pub(super) fn header_count_error() -> Error { + type_value_error_to_magnus("Header collection exceeds 32,768 entries") +} diff --git a/test/header_test.rb b/test/header_test.rb index bfb414c..298d0b7 100644 --- a/test/header_test.rb +++ b/test/header_test.rb @@ -2,289 +2,325 @@ class HeadersTest < Minitest::Test def setup - @response = Wreq.get("#{HTTPBIN_URL}/response-headers", - query: { - "X-Custom-Header" => "custom-value", - "X-Multi-Header" => "value1" - }) - @headers = @response.headers + @headers = Wreq::Headers.new( + "Content-Type" => "application/json", + "X-Custom-Header" => "custom-value" + ) end - def test_headers_class - assert_instance_of Wreq::Headers, @headers - end - - def test_initialize + def test_headers_class_and_empty_constructor headers = Wreq::Headers.new + assert_instance_of Wreq::Headers, headers assert headers.empty? assert_equal 0, headers.length + assert_includes Wreq::Headers.ancestors, Enumerable end - def test_get_existing_header - # Content-Type should exist in response - content_type = @headers.get("Content-Type") - assert_instance_of String, content_type - refute_nil content_type + def test_collection_api_methods_are_available + collection_methods = [:[], :[]=, :fetch, :delete, :size, :to_a, :to_h, :to_hash, :each] + + collection_methods.each do |method| + assert_respond_to @headers, method + end end - def test_get_case_insensitive - # Test case-insensitive lookup - value1 = @headers.get("content-type") - value2 = @headers.get("Content-Type") - value3 = @headers.get("CONTENT-TYPE") + def test_initialize_from_hash_and_headers + headers = Wreq::Headers.new("Accept" => "application/json") + copy = Wreq::Headers.new(headers) - assert_equal value1, value2 - assert_equal value2, value3 - end + headers["Accept"] = "text/plain" - def test_get_nonexistent_header - result = @headers.get("X-Nonexistent-Header-12345") - assert_nil result + assert_equal "text/plain", headers["Accept"] + assert_equal "application/json", copy["Accept"] end - def test_get_all_single_value - # Most headers have single values - values = @headers.get_all("Content-Type") - assert_instance_of Array, values - assert_equal 1, values.length - assert_equal @headers.get("Content-Type"), values.first + def test_initialize_from_enumerable_pairs + source = Class.new do + include Enumerable + + def each + yield "X-First", "one" + yield :set_cookie, ["a=1", "b=2"] + end + end.new + + headers = Wreq::Headers.new(source) + + assert_includes headers.keys, "x-first" + assert_includes headers.keys, "set-cookie" + assert_equal ["a=1", "b=2"], headers[:set_cookie] end - def test_get_all_nonexistent - values = @headers.get_all("X-Nonexistent-Header") - assert_instance_of Array, values - assert_equal 0, values.length - assert_empty values + def test_initialize_rejects_invalid_sources_and_pairs + assert_raises(Wreq::BuilderError) { Wreq::Headers.new(Object.new) } + assert_raises(Wreq::BuilderError) { Wreq::Headers.new([["Accept"]]) } + assert_raises(ArgumentError) { Wreq::Headers.new({}, {}) } end - def test_set_new_header - headers = Wreq::Headers.new - headers.set("X-Test-Header", "test-value") + def test_string_and_symbol_names_are_normalized + headers = Wreq::Headers.new( + "x-CuStOm-Header" => "value", + content_type: "application/json" + ) - assert_equal "test-value", headers.get("X-Test-Header") - assert_equal 1, headers.length + assert_includes headers.keys, "x-custom-header" + assert_includes headers.keys, "content-type" + assert_equal "value", headers["X-CUSTOM-HEADER"] + assert_equal "application/json", headers[:content_type] end - def test_set_replaces_existing - headers = Wreq::Headers.new - headers.set("X-Test", "value1") - headers.set("X-Test", "value2") + def test_get_returns_first_value + headers = Wreq::Headers.new("Accept" => ["application/json", "text/plain"]) - assert_equal "value2", headers.get("X-Test") - values = headers.get_all("X-Test") - assert_equal 1, values.length - assert_equal "value2", values.first + assert_equal "application/json", headers.get("accept") + assert_equal "application/json", headers.get(:accept) + assert_nil headers.get(:missing) end - def test_append_to_new_header - headers = Wreq::Headers.new - headers.append("Accept", "application/json") + def test_index_uses_nil_string_and_array_shapes + headers = Wreq::Headers.new( + "Accept" => "application/json", + "Set-Cookie" => ["a=1", "b=2"] + ) - assert_equal "application/json", headers.get("Accept") + assert_nil headers["Missing"] + assert_equal "application/json", headers["Accept"] + assert_equal ["a=1", "b=2"], headers["Set-Cookie"] end - def test_append_to_existing_header - headers = Wreq::Headers.new - headers.set("Accept", "application/json") - headers.append("Accept", "text/html") - headers.append("Accept", "application/xml") + def test_get_all_always_returns_an_array + assert_equal ["application/json"], @headers.get_all("CONTENT-TYPE") + assert_equal [], @headers.get_all("Missing") + end + + def test_set_replaces_existing_occurrences + headers = Wreq::Headers.new("Accept" => ["application/json", "text/plain"]) + + headers.set("Accept", ["text/html", "application/xml"]) - values = headers.get_all("Accept") - assert_equal 3, values.length - assert_includes values, "application/json" - assert_includes values, "text/html" - assert_includes values, "application/xml" + assert_equal ["text/html", "application/xml"], headers.get_all("Accept") + assert_equal 2, headers.length end - def test_remove_existing_header - headers = Wreq::Headers.new - headers.set("X-Remove-Me", "value") + def test_index_assignment_replaces_existing_occurrences + headers = Wreq::Headers.new("Set-Cookie" => "old=1") - removed_value = headers.remove("X-Remove-Me") - assert_equal "value", removed_value - assert_nil headers.get("X-Remove-Me") + assigned = headers.public_send(:[]=, :set_cookie, ["a=1", "b=2"]) + + assert_equal ["a=1", "b=2"], assigned + assert_equal ["a=1", "b=2"], headers.get_all("Set-Cookie") end - def test_remove_nonexistent_header + def test_append_keeps_values_as_separate_occurrences headers = Wreq::Headers.new - result = headers.remove("X-Nonexistent") - assert_nil result + headers.append("Set-Cookie", "a=1") + headers.append("Set-Cookie", ["b=2", "c=3"]) + + assert_equal ["a=1", "b=2", "c=3"], headers.get_all("Set-Cookie") + refute_includes headers.get_all("Set-Cookie"), "a=1,b=2,c=3" end - def test_delete_alias + def test_set_and_append_return_nil headers = Wreq::Headers.new - headers.set("X-Delete-Me", "value") - removed_value = headers.remove("X-Delete-Me") - assert_equal "value", removed_value - assert_nil headers.get("X-Delete-Me") + assert_nil headers.set("Accept", "application/json") + assert_nil headers.append("Accept", "text/plain") end - def test_contains_existing - assert @headers.contains?("Content-Type") - end + def test_empty_array_values_remove_or_leave_headers_unchanged + headers = Wreq::Headers.new("Accept" => "application/json") + + assert_nil headers.set("Accept", []) + refute headers.key?("Accept") - def test_contains_nonexistent - refute @headers.contains?("X-Nonexistent-Header-12345") + assert_nil headers.append("X-Empty", []) + refute headers.key?("X-Empty") end - def test_contains_case_insensitive - # If Content-Type exists - if @headers.contains?("Content-Type") - assert @headers.contains?("content-type") - assert @headers.contains?("CONTENT-TYPE") - end + def test_fetch_existing_and_missing_values + assert_equal "application/json", @headers.fetch(:content_type) + assert_equal "fallback", @headers.fetch("Missing", "fallback") + assert_equal "MISSING", @headers.fetch("Missing") { |name| name.upcase } + assert_raises(KeyError) { @headers.fetch("Missing") } end - def test_key_alias - # key? is an alias for contains? - assert_equal @headers.contains?("Content-Type"), @headers.key?("Content-Type") + def test_fetch_block_takes_precedence_over_default + result = @headers.fetch("Missing", "fallback") { "from block" } + + assert_equal "from block", result end - def test_length - headers = Wreq::Headers.new - assert_equal 0, headers.length + def test_fetch_preserves_repeated_values_and_explicit_nil_default + headers = Wreq::Headers.new("Set-Cookie" => ["a=1", "b=2"]) - headers.set("Header1", "value1") - assert_equal 1, headers.length + assert_equal ["a=1", "b=2"], headers.fetch(:set_cookie) + assert_nil headers.fetch("Missing", nil) + end - headers.set("Header2", "value2") - assert_equal 2, headers.length + def test_remove_and_delete_remove_every_occurrence + headers = Wreq::Headers.new("Set-Cookie" => ["a=1", "b=2"]) - # Setting same header shouldn't increase length - headers.set("Header1", "new-value") - assert_equal 2, headers.length + assert_equal "a=1", headers.delete(:set_cookie) + assert_nil headers["Set-Cookie"] + assert_nil headers.remove("Set-Cookie") end - def test_empty_on_new_headers - headers = Wreq::Headers.new - assert headers.empty? + def test_contains_and_key_are_case_insensitive + assert @headers.contains?("CONTENT-TYPE") + assert @headers.contains?(:content_type) + assert @headers.key?("content-type") + refute @headers.key?("Missing") end - def test_empty_on_headers_with_data - refute @headers.empty? + def test_length_counts_occurrences_and_keys_are_unique + headers = Wreq::Headers.new( + "Accept" => ["application/json", "text/plain"], + "Content-Type" => "application/json" + ) + + assert_equal 3, headers.length + assert_equal 3, headers.size + assert_equal 2, headers.keys.length end - def test_clear - headers = Wreq::Headers.new - headers.set("Header1", "value1") - headers.set("Header2", "value2") + def test_clear_returns_self + headers = Wreq::Headers.new("Accept" => "application/json") - refute headers.empty? - headers.clear + assert_same headers, headers.clear assert headers.empty? - assert_equal 0, headers.length end - def test_keys - headers = Wreq::Headers.new - headers.set("Content-Type", "application/json") - headers.set("Authorization", "Bearer token") + def test_values_include_every_occurrence + headers = Wreq::Headers.new("Accept" => ["application/json", "text/plain"]) - keys = headers.keys - assert_instance_of Array, keys - assert_equal 2, keys.length - assert_includes keys, "content-type" - assert_includes keys, "authorization" + assert_equal ["application/json", "text/plain"], headers.values end - def test_keys_are_lowercase - headers = Wreq::Headers.new - headers.set("Content-Type", "text/html") - headers.set("X-Custom-Header", "value") + def test_each_yields_every_occurrence_and_returns_self + headers = Wreq::Headers.new("Set-Cookie" => ["a=1", "b=2"]) + pairs = [] - keys = headers.keys - keys.each do |key| - assert_equal key, key.downcase - end + returned = headers.each { |name, value| pairs << [name, value] } + + assert_same headers, returned + assert_equal [["set-cookie", "a=1"], ["set-cookie", "b=2"]], pairs end - def test_values - headers = Wreq::Headers.new - headers.set("Content-Type", "application/json") - headers.set("Authorization", "Bearer token") + def test_each_without_a_block_returns_chainable_enumerator + headers = Wreq::Headers.new( + "Accept" => "application/json", + "Set-Cookie" => ["a=1", "b=2"] + ) + + enumerator = headers.each + cookies = enumerator.select { |name, _value| name == "set-cookie" } - values = headers.values - assert_instance_of Array, values - assert_equal 2, values.length - assert_includes values, "application/json" - assert_includes values, "Bearer token" + assert_instance_of Enumerator, enumerator + assert_equal [["set-cookie", "a=1"], ["set-cookie", "b=2"]], cookies end - def test_each_with_block - headers = Wreq::Headers.new - headers.set("Header1", "value1") - headers.set("Header2", "value2") - - collected = {} - headers.each do |name, value| - assert_instance_of String, name - assert_instance_of String, value - collected[name] = value + def test_each_allows_mutating_headers_from_the_block + headers = Wreq::Headers.new("X-First" => "one") + yielded_names = [] + + headers.each do |name, _value| + yielded_names << name + headers["X-Added"] = "two" end - assert_equal 2, collected.length - assert_equal "value1", collected["header1"] - assert_equal "value2", collected["header2"] + assert_equal ["x-first"], yielded_names + assert_equal "two", headers["X-Added"] end - def test_multiple_operations_sequence - headers = Wreq::Headers.new + def test_enumerable_methods_use_each + headers = Wreq::Headers.new( + "Accept" => "application/json", + "Set-Cookie" => ["a=1", "b=2"] + ) - # Add headers - headers.set("Content-Type", "application/json") - headers.set("Accept", "application/json") + cookies = headers.select { |name, _value| name == "set-cookie" } - assert_equal 2, headers.length + assert_equal [["set-cookie", "a=1"], ["set-cookie", "b=2"]], cookies + end - # Append to Accept - headers.append("Accept", "text/html") - assert_equal 2, headers.get_all("Accept").length + def test_to_a_and_to_h_preserve_duplicate_values + headers = Wreq::Headers.new( + "Accept" => "application/json", + "Set-Cookie" => ["a=1", "b=2"] + ) - # Clear all - headers.clear - assert headers.empty? + expected_pairs = [ + ["accept", "application/json"], + ["set-cookie", "a=1"], + ["set-cookie", "b=2"] + ] + assert_equal expected_pairs.sort, headers.to_a.sort + assert_equal({ + "accept" => "application/json", + "set-cookie" => ["a=1", "b=2"] + }, headers.to_h) + assert_equal headers.to_h, headers.to_hash end def test_special_characters_in_header_values + value = "Bearer token-123_abc/xyz+456=789" + @headers.set("Authorization", value) + + assert_equal value, @headers.get("Authorization") + end + + def test_invalid_header_names_and_values_raise_builder_error headers = Wreq::Headers.new - special_value = "Bearer token-123_abc/xyz+456=789" - headers.set("Authorization", special_value) - assert_equal special_value, headers.get("Authorization") + assert_raises(Wreq::BuilderError) { headers.set(123, "value") } + assert_raises(Wreq::BuilderError) { headers.set("Bad\nName", "value") } + assert_raises(Wreq::BuilderError) { headers.set("X-Test", 123) } + assert_raises(Wreq::BuilderError) { headers.append("X-Test", ["valid", 123]) } + assert headers.empty? end - def test_response_headers_integration - # Test that headers from actual HTTP response work correctly - assert_instance_of Wreq::Headers, @headers - refute @headers.empty? + def test_header_entry_limit_raises_builder_error_without_partial_update + headers = Wreq::Headers.new + values = Array.new(32_769, "value") - # Should have common HTTP headers - assert @headers.length > 0 + error = assert_raises(Wreq::BuilderError) { headers.append("X-Large", values) } + + assert_match(/32,768/, error.message) + assert headers.empty? end - def test_response_headers_each - # Test iteration over real response headers - count = 0 - @headers.each do |name, value| - assert_instance_of String, name - assert_instance_of String, value - count += 1 - end + def test_response_headers_integration + headers = response_headers + pairs = headers.each.to_a - assert count > 0 - assert_equal @headers.length, count + assert_instance_of Wreq::Headers, headers + refute headers.empty? + assert headers.contains?("Content-Type") + assert_equal headers.length, pairs.length end - def test_headers_immutability_across_instances - headers1 = Wreq::Headers.new - headers2 = Wreq::Headers.new + def test_response_headers_are_fresh_mutable_snapshots + response = Wreq.get("#{HTTPBIN_URL}/response-headers", query: {"X-Test" => "original"}) + first = response.headers + second = response.headers + + refute_same first, second + first["X-Test"] = "changed" + first["X-Local"] = "value" + + assert_equal "changed", first["X-Test"] + assert_equal "original", second["X-Test"] + assert_nil second["X-Local"] + assert_equal "original", response.headers["X-Test"] + end - headers1.set("X-Test", "value1") - headers2.set("X-Test", "value2") + private - assert_equal "value1", headers1.get("X-Test") - assert_equal "value2", headers2.get("X-Test") + def response_headers + Wreq.get( + "#{HTTPBIN_URL}/response-headers", + query: {"X-Custom-Header" => "custom-value"} + ).headers end end