Skip to content

Commit 361ec49

Browse files
CopilotiMacTia
andcommitted
Implement BaseOptions and OptionsLike foundation classes
Co-authored-by: iMacTia <[email protected]>
1 parent 168c2e8 commit 361ec49

File tree

5 files changed

+594
-6
lines changed

5 files changed

+594
-6
lines changed

lib/faraday.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
require 'faraday/error'
1111
require 'faraday/middleware_registry'
1212
require 'faraday/utils'
13+
require 'faraday/options_like'
14+
require 'faraday/base_options'
1315
require 'faraday/options'
1416
require 'faraday/connection'
1517
require 'faraday/rack_builder'

lib/faraday/base_options.rb

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# frozen_string_literal: true
2+
3+
module Faraday
4+
# Abstract base class for Options-like classes.
5+
#
6+
# Provides common functionality for nested coercion, deep merging, and duplication.
7+
# Subclasses must define:
8+
# - +MEMBERS+: Array of attribute names (symbols)
9+
# - +COERCIONS+: Hash mapping attribute names to coercion classes
10+
#
11+
# @example Creating a subclass
12+
# class MyOptions < Faraday::BaseOptions
13+
# MEMBERS = [:timeout, :open_timeout].freeze
14+
# COERCIONS = {}.freeze
15+
#
16+
# attr_accessor :timeout, :open_timeout
17+
# end
18+
#
19+
# options = MyOptions.new(timeout: 10)
20+
# options.timeout # => 10
21+
#
22+
# @example With nested coercion
23+
# class ProxyOptions < Faraday::BaseOptions
24+
# MEMBERS = [:uri].freeze
25+
# COERCIONS = { uri: URI }.freeze
26+
#
27+
# attr_accessor :uri
28+
# end
29+
#
30+
# @see OptionsLike
31+
class BaseOptions
32+
include OptionsLike
33+
34+
# Subclasses must define:
35+
# - MEMBERS: Array of attribute names (symbols)
36+
# - COERCIONS: Hash mapping attribute names to coercion classes
37+
38+
class << self
39+
# Create new instance from hash or existing instance.
40+
#
41+
# @param value [nil, Hash, BaseOptions] the value to convert
42+
# @return [BaseOptions] a new instance or the value itself if already correct type
43+
#
44+
# @example
45+
# MyOptions.from(nil) # => empty MyOptions instance
46+
# MyOptions.from(timeout: 10) # => MyOptions with timeout=10
47+
# existing = MyOptions.new(timeout: 10)
48+
# MyOptions.from(existing) # => returns existing (same instance)
49+
def from(value)
50+
return value if value.is_a?(self)
51+
return new if value.nil?
52+
53+
new(value)
54+
end
55+
end
56+
57+
# Initialize a new instance with the given options.
58+
#
59+
# @param options_hash [Hash, #to_hash, nil] options to initialize with as positional arg
60+
# @param options [Hash] options to initialize with as keyword args
61+
# @return [BaseOptions] self
62+
#
63+
# @example
64+
# options = MyOptions.new(timeout: 10, open_timeout: 5)
65+
# options = MyOptions.new({ timeout: 10 })
66+
def initialize(options_hash = nil, **options)
67+
# Merge positional and keyword arguments
68+
if options_hash
69+
options_hash = options_hash.to_hash if options_hash.respond_to?(:to_hash)
70+
options = options_hash.merge(options)
71+
end
72+
73+
self.class::MEMBERS.each do |key|
74+
value = options[key] || options[key.to_s]
75+
value = coerce(key, value)
76+
instance_variable_set(:"@#{key}", value)
77+
end
78+
end
79+
80+
# Update this instance with values from another hash/instance.
81+
#
82+
# @param obj [Hash, #to_hash] the values to update with
83+
# @return [BaseOptions] self
84+
#
85+
# @example
86+
# options = MyOptions.new(timeout: 10)
87+
# options.update(timeout: 20, open_timeout: 5)
88+
# options.timeout # => 20
89+
def update(obj)
90+
obj = obj.to_hash if obj.respond_to?(:to_hash)
91+
obj.each do |key, value|
92+
key = key.to_sym
93+
if self.class::MEMBERS.include?(key)
94+
value = coerce(key, value)
95+
instance_variable_set(:"@#{key}", value)
96+
end
97+
end
98+
self
99+
end
100+
101+
# Non-destructive merge.
102+
#
103+
# Creates a deep copy and merges the given hash/instance into it.
104+
#
105+
# @param obj [Hash, #to_hash] the values to merge
106+
# @return [BaseOptions] a new instance with merged values
107+
#
108+
# @example
109+
# options = MyOptions.new(timeout: 10)
110+
# new_options = options.merge(timeout: 20)
111+
# options.timeout # => 10 (unchanged)
112+
# new_options.timeout # => 20
113+
def merge(obj)
114+
deep_dup.merge!(obj)
115+
end
116+
117+
# Destructive merge using {Utils.deep_merge!}.
118+
#
119+
# @param obj [Hash, #to_hash] the values to merge
120+
# @return [BaseOptions] self
121+
#
122+
# @example
123+
# options = MyOptions.new(timeout: 10)
124+
# options.merge!(timeout: 20)
125+
# options.timeout # => 20
126+
def merge!(obj)
127+
obj = obj.to_hash if obj.respond_to?(:to_hash)
128+
hash = to_hash
129+
Utils.deep_merge!(hash, obj)
130+
update(hash)
131+
end
132+
133+
# Create a deep duplicate of this instance.
134+
#
135+
# @return [BaseOptions] a new instance with deeply duplicated values
136+
#
137+
# @example
138+
# original = MyOptions.new(timeout: 10)
139+
# copy = original.deep_dup
140+
# copy.timeout = 20
141+
# original.timeout # => 10 (unchanged)
142+
def deep_dup
143+
self.class.new(
144+
self.class::MEMBERS.each_with_object({}) do |key, hash|
145+
value = instance_variable_get(:"@#{key}")
146+
hash[key] = Utils.deep_dup(value)
147+
end
148+
)
149+
end
150+
151+
# Convert to a hash.
152+
#
153+
# @return [Hash] hash representation with symbol keys
154+
#
155+
# @example
156+
# options = MyOptions.new(timeout: 10)
157+
# options.to_hash # => { timeout: 10 }
158+
def to_hash
159+
self.class::MEMBERS.each_with_object({}) do |key, hash|
160+
hash[key] = instance_variable_get(:"@#{key}")
161+
end
162+
end
163+
164+
# Inspect the instance.
165+
#
166+
# @return [String] human-readable representation
167+
#
168+
# @example
169+
# options = MyOptions.new(timeout: 10)
170+
# options.inspect # => "#<MyOptions {:timeout=>10}>"
171+
def inspect
172+
"#<#{self.class} #{to_hash.inspect}>"
173+
end
174+
175+
private
176+
177+
# Coerce a value based on the COERCIONS configuration.
178+
#
179+
# @param key [Symbol] the attribute name
180+
# @param value [Object] the value to coerce
181+
# @return [Object] the coerced value or original if no coercion defined
182+
def coerce(key, value)
183+
coercion = self.class::COERCIONS[key]
184+
return value unless coercion
185+
return value if value.nil?
186+
return value if value.is_a?(coercion)
187+
188+
coercion.from(value)
189+
end
190+
end
191+
end

lib/faraday/options_like.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
module Faraday
4+
# Marker module for Options-like objects.
5+
#
6+
# This module enables duck-typed interoperability between legacy {Options}
7+
# and new {BaseOptions} classes. It provides a stable interface for:
8+
# - Integration with {Utils.deep_merge!}
9+
# - Type checking in option coercion logic
10+
# - Uniform handling of option objects across the codebase
11+
#
12+
# @example Including in custom options classes
13+
# class MyOptions
14+
# include Faraday::OptionsLike
15+
#
16+
# def to_hash
17+
# { key: value }
18+
# end
19+
# end
20+
#
21+
# @see BaseOptions
22+
# @see Options
23+
module OptionsLike
24+
end
25+
end

lib/faraday/utils.rb

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,48 @@ def normalize_path(url)
100100
# Recursive hash update
101101
def deep_merge!(target, hash)
102102
hash.each do |key, value|
103-
target[key] = if value.is_a?(Hash) && (target[key].is_a?(Hash) || target[key].is_a?(Options))
104-
deep_merge(target[key], value)
105-
else
106-
value
107-
end
103+
target_value = target[key]
104+
mergeable = value.is_a?(Hash) &&
105+
(target_value.is_a?(Hash) || target_value.is_a?(Options) || target_value.is_a?(OptionsLike))
106+
target[key] = mergeable ? deep_merge(target_value, value) : value
108107
end
109108
target
110109
end
111110

111+
# Deep duplication of values
112+
#
113+
# @param value [Object] the value to duplicate
114+
# @return [Object] a deep copy of the value
115+
def deep_dup(value)
116+
case value
117+
when Hash
118+
value.transform_values do |v|
119+
deep_dup(v)
120+
end
121+
when Array
122+
value.map { |v| deep_dup(v) }
123+
when OptionsLike
124+
value.deep_dup
125+
else
126+
# For primitive types and objects without special dup needs
127+
begin
128+
value.dup
129+
rescue TypeError
130+
# Some objects like true, false, nil, numbers can't be duped
131+
value
132+
end
133+
end
134+
end
135+
112136
# Recursive hash merge
113137
def deep_merge(source, hash)
114-
deep_merge!(source.dup, hash)
138+
# For OptionsLike objects (but not Options which is a Struct),
139+
# we need to convert to hash, merge, and convert back
140+
if source.is_a?(OptionsLike) && !source.is_a?(Options)
141+
source.class.from(deep_merge!(source.to_hash, hash))
142+
else
143+
deep_merge!(source.dup, hash)
144+
end
115145
end
116146

117147
def sort_query_params(query)

0 commit comments

Comments
 (0)