diff --git a/lib/faraday.rb b/lib/faraday.rb index 34f327031..dce96d2be 100644 --- a/lib/faraday.rb +++ b/lib/faraday.rb @@ -10,6 +10,8 @@ require 'faraday/error' require 'faraday/middleware_registry' require 'faraday/utils' +require 'faraday/options_like' +require 'faraday/base_options' require 'faraday/options' require 'faraday/connection' require 'faraday/rack_builder' diff --git a/lib/faraday/base_options.rb b/lib/faraday/base_options.rb new file mode 100644 index 000000000..1e57fbf03 --- /dev/null +++ b/lib/faraday/base_options.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +module Faraday + # Abstract base class for Options-like classes. + # + # Provides common functionality for nested coercion, deep merging, and duplication. + # Subclasses must define: + # - +MEMBERS+: Array of attribute names (symbols) + # - +COERCIONS+: Hash mapping attribute names to coercion classes + # + # @example Creating a subclass + # class MyOptions < Faraday::BaseOptions + # MEMBERS = [:timeout, :open_timeout].freeze + # COERCIONS = {}.freeze + # + # attr_accessor :timeout, :open_timeout + # end + # + # options = MyOptions.new(timeout: 10) + # options.timeout # => 10 + # + # @example With nested coercion + # class ProxyOptions < Faraday::BaseOptions + # MEMBERS = [:uri].freeze + # COERCIONS = { uri: URI }.freeze + # + # attr_accessor :uri + # end + # + # @see OptionsLike + class BaseOptions + include OptionsLike + + # Subclasses must define: + # - MEMBERS: Array of attribute names (symbols) + # - COERCIONS: Hash mapping attribute names to coercion classes + + class << self + # Create new instance from hash or existing instance. + # + # @param value [nil, Hash, BaseOptions] the value to convert + # @return [BaseOptions] a new instance or the value itself if already correct type + # + # @example + # MyOptions.from(nil) # => empty MyOptions instance + # MyOptions.from(timeout: 10) # => MyOptions with timeout=10 + # existing = MyOptions.new(timeout: 10) + # MyOptions.from(existing) # => returns existing (same instance) + def from(value) + return value if value.is_a?(self) + return new if value.nil? + + new(value) + end + end + + # Initialize a new instance with the given options. + # + # @param options_hash [Hash, #to_hash, nil] options to initialize with as positional arg + # @param options [Hash] options to initialize with as keyword args + # @return [BaseOptions] self + # + # @example + # options = MyOptions.new(timeout: 10, open_timeout: 5) + # options = MyOptions.new({ timeout: 10 }) + def initialize(options_hash = nil, **options) + # Merge positional and keyword arguments + if options_hash + options_hash = options_hash.to_hash if options_hash.respond_to?(:to_hash) + options = options_hash.merge(options) + end + + self.class::MEMBERS.each do |key| + value = options[key] || options[key.to_s] + value = coerce(key, value) + instance_variable_set(:"@#{key}", value) + end + end + + # Update this instance with values from another hash/instance. + # + # @param obj [Hash, #to_hash] the values to update with + # @return [BaseOptions] self + # + # @example + # options = MyOptions.new(timeout: 10) + # options.update(timeout: 20, open_timeout: 5) + # options.timeout # => 20 + def update(obj) + obj = obj.to_hash if obj.respond_to?(:to_hash) + obj.each do |key, value| + key = key.to_sym + if self.class::MEMBERS.include?(key) + value = coerce(key, value) + instance_variable_set(:"@#{key}", value) + end + end + self + end + + # Non-destructive merge. + # + # Creates a deep copy and merges the given hash/instance into it. + # + # @param obj [Hash, #to_hash] the values to merge + # @return [BaseOptions] a new instance with merged values + # + # @example + # options = MyOptions.new(timeout: 10) + # new_options = options.merge(timeout: 20) + # options.timeout # => 10 (unchanged) + # new_options.timeout # => 20 + def merge(obj) + deep_dup.merge!(obj) + end + + # Destructive merge using {Utils.deep_merge!}. + # + # @param obj [Hash, #to_hash] the values to merge + # @return [BaseOptions] self + # + # @example + # options = MyOptions.new(timeout: 10) + # options.merge!(timeout: 20) + # options.timeout # => 20 + def merge!(obj) + obj = obj.to_hash if obj.respond_to?(:to_hash) + hash = to_hash + Utils.deep_merge!(hash, obj) + update(hash) + end + + # Create a deep duplicate of this instance. + # + # @return [BaseOptions] a new instance with deeply duplicated values + # + # @example + # original = MyOptions.new(timeout: 10) + # copy = original.deep_dup + # copy.timeout = 20 + # original.timeout # => 10 (unchanged) + def deep_dup + self.class.new( + self.class::MEMBERS.each_with_object({}) do |key, hash| + value = instance_variable_get(:"@#{key}") + hash[key] = Utils.deep_dup(value) + end + ) + end + + # Convert to a hash. + # + # @return [Hash] hash representation with symbol keys + # + # @example + # options = MyOptions.new(timeout: 10) + # options.to_hash # => { timeout: 10 } + def to_hash + self.class::MEMBERS.each_with_object({}) do |key, hash| + hash[key] = instance_variable_get(:"@#{key}") + end + end + + # Inspect the instance. + # + # @return [String] human-readable representation + # + # @example + # options = MyOptions.new(timeout: 10) + # options.inspect # => "#10}>" + def inspect + "#<#{self.class} #{to_hash.inspect}>" + end + + private + + # Coerce a value based on the COERCIONS configuration. + # + # @param key [Symbol] the attribute name + # @param value [Object] the value to coerce + # @return [Object] the coerced value or original if no coercion defined + def coerce(key, value) + coercion = self.class::COERCIONS[key] + return value unless coercion + return value if value.nil? + return value if value.is_a?(coercion) + + coercion.from(value) + end + end +end diff --git a/lib/faraday/options_like.rb b/lib/faraday/options_like.rb new file mode 100644 index 000000000..d6b7c491e --- /dev/null +++ b/lib/faraday/options_like.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Faraday + # Marker module for Options-like objects. + # + # This module enables duck-typed interoperability between legacy {Options} + # and new {BaseOptions} classes. It provides a stable interface for: + # - Integration with {Utils.deep_merge!} + # - Type checking in option coercion logic + # - Uniform handling of option objects across the codebase + # + # @example Including in custom options classes + # class MyOptions + # include Faraday::OptionsLike + # + # def to_hash + # { key: value } + # end + # end + # + # @see BaseOptions + # @see Options + module OptionsLike + end +end diff --git a/lib/faraday/utils.rb b/lib/faraday/utils.rb index 809b3a88e..405eba87d 100644 --- a/lib/faraday/utils.rb +++ b/lib/faraday/utils.rb @@ -100,18 +100,48 @@ def normalize_path(url) # Recursive hash update def deep_merge!(target, hash) hash.each do |key, value| - target[key] = if value.is_a?(Hash) && (target[key].is_a?(Hash) || target[key].is_a?(Options)) - deep_merge(target[key], value) - else - value - end + target_value = target[key] + mergeable = value.is_a?(Hash) && + (target_value.is_a?(Hash) || target_value.is_a?(Options) || target_value.is_a?(OptionsLike)) + target[key] = mergeable ? deep_merge(target_value, value) : value end target end + # Deep duplication of values + # + # @param value [Object] the value to duplicate + # @return [Object] a deep copy of the value + def deep_dup(value) + case value + when Hash + value.transform_values do |v| + deep_dup(v) + end + when Array + value.map { |v| deep_dup(v) } + when OptionsLike + value.deep_dup + else + # For primitive types and objects without special dup needs + begin + value.dup + rescue TypeError + # Some objects like true, false, nil, numbers can't be duped + value + end + end + end + # Recursive hash merge def deep_merge(source, hash) - deep_merge!(source.dup, hash) + # For OptionsLike objects (but not Options which is a Struct), + # we need to convert to hash, merge, and convert back + if source.is_a?(OptionsLike) && !source.is_a?(Options) + source.class.from(deep_merge!(source.to_hash, hash)) + else + deep_merge!(source.dup, hash) + end end def sort_query_params(query) diff --git a/spec/faraday/base_options_spec.rb b/spec/faraday/base_options_spec.rb new file mode 100644 index 000000000..ac21756b0 --- /dev/null +++ b/spec/faraday/base_options_spec.rb @@ -0,0 +1,340 @@ +# frozen_string_literal: true + +RSpec.describe Faraday::BaseOptions do + # Test subclasses to validate BaseOptions behavior + # Using Test prefix to avoid conflicts with existing classes + module TestClasses + class SimpleOptions < Faraday::BaseOptions + MEMBERS = %i[timeout open_timeout].freeze + COERCIONS = {}.freeze + + attr_accessor :timeout, :open_timeout + end + + class NestedOptions < Faraday::BaseOptions + MEMBERS = [:value].freeze + COERCIONS = {}.freeze + + attr_accessor :value + end + + class ParentOptions < Faraday::BaseOptions + MEMBERS = %i[name count nested].freeze + COERCIONS = { nested: NestedOptions }.freeze + + attr_accessor :name, :count, :nested + end + end + + let(:simple_options_class) { TestClasses::SimpleOptions } + let(:nested_options_class) { TestClasses::NestedOptions } + let(:parent_options_class) { TestClasses::ParentOptions } + + describe 'OptionsLike inclusion' do + it 'includes OptionsLike module' do + expect(simple_options_class.new).to be_a(Faraday::OptionsLike) + end + end + + describe '.from' do + context 'with nil' do + it 'returns new instance' do + result = simple_options_class.from(nil) + expect(result).to be_a(simple_options_class) + expect(result.timeout).to be_nil + end + end + + context 'with instance of same class' do + it 'returns the same instance' do + original = simple_options_class.new(timeout: 10) + result = simple_options_class.from(original) + expect(result).to equal(original) + end + end + + context 'with hash' do + it 'creates new instance from hash' do + result = simple_options_class.from(timeout: 10, open_timeout: 5) + expect(result).to be_a(simple_options_class) + expect(result.timeout).to eq(10) + expect(result.open_timeout).to eq(5) + end + end + + context 'with object responding to to_hash' do + it 'creates new instance from to_hash result' do + hash_like = double('hash_like', to_hash: { timeout: 10 }) + result = simple_options_class.from(hash_like) + expect(result).to be_a(simple_options_class) + expect(result.timeout).to eq(10) + end + end + end + + describe '#initialize' do + context 'with empty hash' do + it 'creates instance with nil values' do + options = simple_options_class.new + expect(options.timeout).to be_nil + expect(options.open_timeout).to be_nil + end + end + + context 'with symbol keys' do + it 'sets values from hash' do + options = simple_options_class.new(timeout: 10, open_timeout: 5) + expect(options.timeout).to eq(10) + expect(options.open_timeout).to eq(5) + end + end + + context 'with string keys' do + it 'sets values from hash with string keys' do + options = simple_options_class.new('timeout' => 10, 'open_timeout' => 5) + expect(options.timeout).to eq(10) + expect(options.open_timeout).to eq(5) + end + end + + context 'with object responding to to_hash' do + it 'converts to hash before processing' do + hash_like = double('hash_like', to_hash: { timeout: 10 }) + options = simple_options_class.new(hash_like) + expect(options.timeout).to eq(10) + end + end + + context 'with unknown keys' do + it 'ignores unknown keys' do + options = simple_options_class.new(timeout: 10, unknown: 'value') + expect(options.timeout).to eq(10) + expect(options).not_to respond_to(:unknown) + end + end + end + + describe '#update' do + it 'updates existing instance with new values' do + options = simple_options_class.new(timeout: 10) + result = options.update(timeout: 20, open_timeout: 5) + expect(result).to equal(options) + expect(options.timeout).to eq(20) + expect(options.open_timeout).to eq(5) + end + + it 'accepts object responding to to_hash' do + options = simple_options_class.new(timeout: 10) + hash_like = double('hash_like', to_hash: { timeout: 20 }) + options.update(hash_like) + expect(options.timeout).to eq(20) + end + + it 'ignores unknown keys' do + options = simple_options_class.new(timeout: 10) + options.update(timeout: 20, unknown: 'value') + expect(options.timeout).to eq(20) + end + + it 'returns self' do + options = simple_options_class.new + result = options.update(timeout: 10) + expect(result).to equal(options) + end + end + + describe '#merge' do + it 'returns new instance with merged values' do + options = simple_options_class.new(timeout: 10) + result = options.merge(timeout: 20, open_timeout: 5) + expect(result).to be_a(simple_options_class) + expect(result).not_to equal(options) + expect(result.timeout).to eq(20) + expect(result.open_timeout).to eq(5) + expect(options.timeout).to eq(10) + expect(options.open_timeout).to be_nil + end + + it 'deeply merges nested options' do + nested1 = nested_options_class.new(value: 'a') + nested2_hash = { value: 'b' } + options1 = parent_options_class.new(name: 'test', nested: nested1) + result = options1.merge(nested: nested2_hash) + + expect(result.name).to eq('test') + expect(result.nested).to be_a(nested_options_class) + expect(result.nested.value).to eq('b') + expect(options1.nested.value).to eq('a') + end + end + + describe '#merge!' do + it 'updates instance in place with merged values' do + options = simple_options_class.new(timeout: 10) + result = options.merge!(timeout: 20, open_timeout: 5) + expect(result).to equal(options) + expect(options.timeout).to eq(20) + expect(options.open_timeout).to eq(5) + end + + it 'uses Utils.deep_merge! for nested structures' do + nested = nested_options_class.new(value: 'a') + options = parent_options_class.new(name: 'test', nested: nested) + # rubocop:disable Performance/RedundantMerge + options.merge!(nested: { value: 'b' }) + # rubocop:enable Performance/RedundantMerge + + expect(options.nested).to be_a(nested_options_class) + expect(options.nested.value).to eq('b') + end + + it 'accepts object responding to to_hash' do + options = simple_options_class.new(timeout: 10) + hash_like = double('hash_like', to_hash: { timeout: 20 }) + options.merge!(hash_like) + expect(options.timeout).to eq(20) + end + end + + describe '#deep_dup' do + it 'creates deep copy of instance' do + options = simple_options_class.new(timeout: 10, open_timeout: 5) + duped = options.deep_dup + + expect(duped).to be_a(simple_options_class) + expect(duped).not_to equal(options) + expect(duped.timeout).to eq(10) + expect(duped.open_timeout).to eq(5) + + duped.timeout = 20 + expect(options.timeout).to eq(10) + end + + it 'deeply duplicates nested options' do + nested = nested_options_class.new(value: 'original') + options = parent_options_class.new(name: 'test', nested: nested) + duped = options.deep_dup + + expect(duped.nested).to be_a(nested_options_class) + expect(duped.nested).not_to equal(nested) + expect(duped.nested.value).to eq('original') + + duped.nested.value = 'modified' + expect(options.nested.value).to eq('original') + end + + it 'deeply duplicates hash values' do + options = parent_options_class.new(name: 'test') + # Manually set a hash that needs deep duplication + options.instance_variable_set(:@count, { nested: { value: 1 } }) + + duped = options.deep_dup + duped_count = duped.instance_variable_get(:@count) + original_count = options.instance_variable_get(:@count) + + expect(duped_count).not_to equal(original_count) + duped_count[:nested][:value] = 2 + expect(original_count[:nested][:value]).to eq(1) + end + + it 'deeply duplicates array values' do + options = simple_options_class.new + options.instance_variable_set(:@timeout, [1, 2, { key: 'value' }]) + + duped = options.deep_dup + duped_timeout = duped.instance_variable_get(:@timeout) + original_timeout = options.instance_variable_get(:@timeout) + + expect(duped_timeout).not_to equal(original_timeout) + duped_timeout[2][:key] = 'modified' + expect(original_timeout[2][:key]).to eq('value') + end + end + + describe '#to_hash' do + it 'converts instance to hash' do + options = simple_options_class.new(timeout: 10, open_timeout: 5) + hash = options.to_hash + + expect(hash).to be_a(Hash) + expect(hash[:timeout]).to eq(10) + expect(hash[:open_timeout]).to eq(5) + end + + it 'includes nil values' do + options = simple_options_class.new(timeout: 10) + hash = options.to_hash + + expect(hash).to have_key(:timeout) + expect(hash).to have_key(:open_timeout) + expect(hash[:open_timeout]).to be_nil + end + + it 'returns hash with symbol keys' do + options = simple_options_class.new(timeout: 10) + hash = options.to_hash + + expect(hash.keys).to all(be_a(Symbol)) + end + end + + describe '#inspect' do + it 'returns human-readable representation' do + options = simple_options_class.new(timeout: 10, open_timeout: 5) + result = options.inspect + + expect(result).to match(/^#<.*SimpleOptions/) + expect(result).to include('timeout') + expect(result).to include('10') + end + + it 'includes nil values in representation' do + options = simple_options_class.new(timeout: 10) + result = options.inspect + + expect(result).to include('timeout') + expect(result).to include('open_timeout') + end + end + + describe 'nested coercion' do + it 'coerces nested values based on COERCIONS' do + options = parent_options_class.new(nested: { value: 'test' }) + expect(options.nested).to be_a(nested_options_class) + expect(options.nested.value).to eq('test') + end + + it 'does not coerce if value is already correct type' do + nested = nested_options_class.new(value: 'test') + options = parent_options_class.new(nested: nested) + expect(options.nested).to equal(nested) + end + + it 'handles nil nested values' do + options = parent_options_class.new(nested: nil) + expect(options.nested).to be_nil + end + + it 'coerces in update' do + options = parent_options_class.new + options.update(nested: { value: 'updated' }) + expect(options.nested).to be_a(nested_options_class) + expect(options.nested.value).to eq('updated') + end + end + + describe 'inheritance' do + it 'works with subclasses' do + subclass = Class.new(simple_options_class) + options = subclass.new(timeout: 10) + expect(options).to be_a(subclass) + expect(options.timeout).to eq(10) + end + + it 'from returns correct subclass' do + subclass = Class.new(simple_options_class) + options = subclass.from(timeout: 10) + expect(options).to be_a(subclass) + end + end +end