schwab 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.brakeman.yml +75 -0
- data/.claude/commands/release-pr.md +120 -0
- data/.env.example +15 -0
- data/.rspec +3 -0
- data/.rubocop.yml +25 -0
- data/CHANGELOG.md +115 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +12 -0
- data/docs/resource_objects.md +474 -0
- data/lib/schwab/account_number_resolver.rb +123 -0
- data/lib/schwab/accounts.rb +331 -0
- data/lib/schwab/client.rb +266 -0
- data/lib/schwab/configuration.rb +140 -0
- data/lib/schwab/connection.rb +81 -0
- data/lib/schwab/error.rb +51 -0
- data/lib/schwab/market_data.rb +179 -0
- data/lib/schwab/middleware/authentication.rb +100 -0
- data/lib/schwab/middleware/rate_limit.rb +119 -0
- data/lib/schwab/oauth.rb +95 -0
- data/lib/schwab/resources/account.rb +272 -0
- data/lib/schwab/resources/base.rb +300 -0
- data/lib/schwab/resources/order.rb +441 -0
- data/lib/schwab/resources/position.rb +318 -0
- data/lib/schwab/resources/strategy.rb +410 -0
- data/lib/schwab/resources/transaction.rb +333 -0
- data/lib/schwab/version.rb +6 -0
- data/lib/schwab.rb +46 -0
- data/sig/schwab.rbs +4 -0
- data/tasks/prd-accounts-trading-api.md +302 -0
- data/tasks/tasks-prd-accounts-trading-api-reordered.md +140 -0
- data/tasks/tasks-prd-accounts-trading-api.md +106 -0
- metadata +146 -0
data/lib/schwab/oauth.rb
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "oauth2"
|
4
|
+
require "uri"
|
5
|
+
require "securerandom"
|
6
|
+
|
7
|
+
module Schwab
|
8
|
+
# OAuth 2.0 authentication helpers for Schwab API
|
9
|
+
module OAuth
|
10
|
+
class << self
|
11
|
+
# Generate the authorization URL for the OAuth 2.0 flow
|
12
|
+
#
|
13
|
+
# @param client_id [String] Your Schwab application's client ID
|
14
|
+
# @param redirect_uri [String] The redirect URI configured in your Schwab application
|
15
|
+
# @param state [String, nil] Optional state parameter for CSRF protection (will be generated if not provided)
|
16
|
+
# @param config [Configuration, nil] Optional configuration object (uses global config if not provided)
|
17
|
+
# @return [String] The authorization URL to redirect the user to
|
18
|
+
def authorization_url(client_id:, redirect_uri:, state: nil, config: nil)
|
19
|
+
config ||= Schwab.configuration || Configuration.new
|
20
|
+
state ||= SecureRandom.hex(16)
|
21
|
+
|
22
|
+
params = {
|
23
|
+
response_type: "code",
|
24
|
+
client_id: client_id,
|
25
|
+
redirect_uri: redirect_uri,
|
26
|
+
state: state,
|
27
|
+
}
|
28
|
+
|
29
|
+
uri = URI(config.oauth_authorize_url)
|
30
|
+
uri.query = URI.encode_www_form(params)
|
31
|
+
uri.to_s
|
32
|
+
end
|
33
|
+
|
34
|
+
# Exchange an authorization code for access and refresh tokens
|
35
|
+
#
|
36
|
+
# @param code [String] The authorization code from the OAuth callback
|
37
|
+
# @param client_id [String] Your Schwab application's client ID
|
38
|
+
# @param client_secret [String] Your Schwab application's client secret
|
39
|
+
# @param redirect_uri [String] The redirect URI used in the authorization request
|
40
|
+
# @param config [Configuration, nil] Optional configuration object (uses global config if not provided)
|
41
|
+
# @return [Hash] Token response with :access_token, :refresh_token, :expires_in, :expires_at
|
42
|
+
def get_token(code:, client_id:, client_secret:, redirect_uri:, config: nil)
|
43
|
+
config ||= Schwab.configuration || Configuration.new
|
44
|
+
client = oauth2_client(client_id: client_id, client_secret: client_secret, config: config)
|
45
|
+
|
46
|
+
token = client.auth_code.get_token(
|
47
|
+
code,
|
48
|
+
redirect_uri: redirect_uri,
|
49
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
50
|
+
)
|
51
|
+
|
52
|
+
parse_token_response(token)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Refresh an access token using a refresh token
|
56
|
+
#
|
57
|
+
# @param refresh_token [String] The refresh token
|
58
|
+
# @param client_id [String] Your Schwab application's client ID
|
59
|
+
# @param client_secret [String] Your Schwab application's client secret
|
60
|
+
# @param config [Configuration, nil] Optional configuration object (uses global config if not provided)
|
61
|
+
# @return [Hash] Token response with :access_token, :refresh_token, :expires_in, :expires_at
|
62
|
+
def refresh_token(refresh_token:, client_id:, client_secret:, config: nil)
|
63
|
+
config ||= Schwab.configuration || Configuration.new
|
64
|
+
client = oauth2_client(client_id: client_id, client_secret: client_secret, config: config)
|
65
|
+
|
66
|
+
token = OAuth2::AccessToken.new(client, nil, refresh_token: refresh_token)
|
67
|
+
new_token = token.refresh!
|
68
|
+
|
69
|
+
parse_token_response(new_token)
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def oauth2_client(client_id:, client_secret:, config:)
|
75
|
+
OAuth2::Client.new(
|
76
|
+
client_id,
|
77
|
+
client_secret,
|
78
|
+
site: config.api_base_url,
|
79
|
+
authorize_url: config.oauth_authorize_url,
|
80
|
+
token_url: config.oauth_token_url,
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
def parse_token_response(token)
|
85
|
+
{
|
86
|
+
access_token: token.token,
|
87
|
+
refresh_token: token.refresh_token,
|
88
|
+
expires_in: token.expires_in,
|
89
|
+
expires_at: token.expires_at ? Time.at(token.expires_at) : nil,
|
90
|
+
token_type: token.params["token_type"] || "Bearer",
|
91
|
+
}
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,272 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base"
|
4
|
+
|
5
|
+
module Schwab
|
6
|
+
module Resources
|
7
|
+
# Resource wrapper for account objects
|
8
|
+
# Provides account-specific helper methods and type coercions
|
9
|
+
class Account < Base
|
10
|
+
# Set up field type coercions for account fields
|
11
|
+
set_field_type :created_time, :datetime
|
12
|
+
set_field_type :opened_date, :date
|
13
|
+
set_field_type :closed_date, :date
|
14
|
+
set_field_type :last_updated, :datetime
|
15
|
+
set_field_type :day_trader, :boolean
|
16
|
+
set_field_type :closing_only_restricted, :boolean
|
17
|
+
set_field_type :pdt_flag, :boolean
|
18
|
+
set_field_type :round_trips, :integer
|
19
|
+
|
20
|
+
# Get the account number/ID (plain text)
|
21
|
+
#
|
22
|
+
# @return [String] The account number
|
23
|
+
def account_number
|
24
|
+
self[:accountNumber] || self[:account_number]
|
25
|
+
end
|
26
|
+
alias_method :id, :account_number
|
27
|
+
|
28
|
+
# Get the encrypted hash value for this account
|
29
|
+
#
|
30
|
+
# @return [String, nil] The encrypted hash value used in API calls
|
31
|
+
def hash_value
|
32
|
+
self[:hashValue] || self[:hash_value]
|
33
|
+
end
|
34
|
+
alias_method :encrypted_id, :hash_value
|
35
|
+
|
36
|
+
# Get the appropriate account identifier for API calls
|
37
|
+
# Returns hash_value if available, otherwise account_number
|
38
|
+
#
|
39
|
+
# @return [String] The account identifier to use in API calls
|
40
|
+
def api_identifier
|
41
|
+
hash_value || account_number
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get the account type
|
45
|
+
#
|
46
|
+
# @return [String] The account type (e.g., "CASH", "MARGIN")
|
47
|
+
def account_type
|
48
|
+
self[:type] || self[:accountType] || self[:account_type]
|
49
|
+
end
|
50
|
+
|
51
|
+
# Check if this is a margin account
|
52
|
+
#
|
53
|
+
# @return [Boolean] True if margin account
|
54
|
+
def margin_account?
|
55
|
+
account_type == "MARGIN"
|
56
|
+
end
|
57
|
+
|
58
|
+
# Check if this is a cash account
|
59
|
+
#
|
60
|
+
# @return [Boolean] True if cash account
|
61
|
+
def cash_account?
|
62
|
+
account_type == "CASH"
|
63
|
+
end
|
64
|
+
|
65
|
+
# Get account status
|
66
|
+
#
|
67
|
+
# @return [String] The account status
|
68
|
+
def status
|
69
|
+
self[:status] || self[:accountStatus]
|
70
|
+
end
|
71
|
+
|
72
|
+
# Check if account is active
|
73
|
+
#
|
74
|
+
# @return [Boolean] True if account is active
|
75
|
+
def active?
|
76
|
+
status == "ACTIVE"
|
77
|
+
end
|
78
|
+
|
79
|
+
# Get the current balances
|
80
|
+
#
|
81
|
+
# @return [Schwab::Resources::Base] The current balances object
|
82
|
+
def current_balances
|
83
|
+
self[:currentBalances] || self[:current_balances]
|
84
|
+
end
|
85
|
+
|
86
|
+
# Get the initial balances
|
87
|
+
#
|
88
|
+
# @return [Schwab::Resources::Base] The initial balances object
|
89
|
+
def initial_balances
|
90
|
+
self[:initialBalances] || self[:initial_balances]
|
91
|
+
end
|
92
|
+
|
93
|
+
# Get projected balances
|
94
|
+
#
|
95
|
+
# @return [Schwab::Resources::Base] The projected balances object
|
96
|
+
def projected_balances
|
97
|
+
self[:projectedBalances] || self[:projected_balances]
|
98
|
+
end
|
99
|
+
|
100
|
+
# Get positions
|
101
|
+
#
|
102
|
+
# @return [Array<Schwab::Resources::Position>] Array of positions
|
103
|
+
def positions
|
104
|
+
positions_data = self[:positions] || []
|
105
|
+
positions_data.map do |position_data|
|
106
|
+
if position_data.is_a?(Position)
|
107
|
+
position_data
|
108
|
+
else
|
109
|
+
Position.new(position_data, client)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Get account value (net liquidation value)
|
115
|
+
#
|
116
|
+
# @return [Float, nil] The total account value
|
117
|
+
def account_value
|
118
|
+
return unless current_balances
|
119
|
+
|
120
|
+
current_balances[:liquidationValue] ||
|
121
|
+
current_balances[:liquidation_value] ||
|
122
|
+
current_balances[:totalValue] ||
|
123
|
+
current_balances[:total_value]
|
124
|
+
end
|
125
|
+
alias_method :net_liquidation_value, :account_value
|
126
|
+
alias_method :total_value, :account_value
|
127
|
+
|
128
|
+
# Get cash balance
|
129
|
+
#
|
130
|
+
# @return [Float, nil] The cash balance
|
131
|
+
def cash_balance
|
132
|
+
return unless current_balances
|
133
|
+
|
134
|
+
current_balances[:cashBalance] ||
|
135
|
+
current_balances[:cash_balance] ||
|
136
|
+
current_balances[:availableFunds] ||
|
137
|
+
current_balances[:available_funds]
|
138
|
+
end
|
139
|
+
|
140
|
+
# Get buying power
|
141
|
+
#
|
142
|
+
# @return [Float, nil] The buying power
|
143
|
+
def buying_power
|
144
|
+
return unless current_balances
|
145
|
+
|
146
|
+
current_balances[:buyingPower] ||
|
147
|
+
current_balances[:buying_power] ||
|
148
|
+
current_balances[:availableFundsTrade] ||
|
149
|
+
current_balances[:available_funds_trade]
|
150
|
+
end
|
151
|
+
|
152
|
+
# Get day trading buying power
|
153
|
+
#
|
154
|
+
# @return [Float, nil] The day trading buying power
|
155
|
+
def day_trading_buying_power
|
156
|
+
return unless current_balances
|
157
|
+
|
158
|
+
current_balances[:dayTradingBuyingPower] ||
|
159
|
+
current_balances[:day_trading_buying_power]
|
160
|
+
end
|
161
|
+
|
162
|
+
# Get maintenance requirement
|
163
|
+
#
|
164
|
+
# @return [Float, nil] The maintenance requirement
|
165
|
+
def maintenance_requirement
|
166
|
+
return unless current_balances
|
167
|
+
|
168
|
+
current_balances[:maintenanceRequirement] ||
|
169
|
+
current_balances[:maintenance_requirement] ||
|
170
|
+
current_balances[:maintReq] ||
|
171
|
+
current_balances[:maint_req]
|
172
|
+
end
|
173
|
+
|
174
|
+
# Get margin balance if applicable
|
175
|
+
#
|
176
|
+
# @return [Float, nil] The margin balance
|
177
|
+
def margin_balance
|
178
|
+
return unless margin_account? && current_balances
|
179
|
+
|
180
|
+
current_balances[:marginBalance] ||
|
181
|
+
current_balances[:margin_balance]
|
182
|
+
end
|
183
|
+
|
184
|
+
# Check if account is in margin call
|
185
|
+
#
|
186
|
+
# @return [Boolean, nil] True if in margin call
|
187
|
+
def margin_call?
|
188
|
+
return false unless margin_account? && current_balances
|
189
|
+
|
190
|
+
in_call = current_balances[:isInCall] ||
|
191
|
+
current_balances[:is_in_call] ||
|
192
|
+
current_balances[:inCall] ||
|
193
|
+
current_balances[:in_call]
|
194
|
+
|
195
|
+
!!in_call
|
196
|
+
end
|
197
|
+
|
198
|
+
# Get account equity
|
199
|
+
#
|
200
|
+
# @return [Float, nil] The account equity
|
201
|
+
def equity
|
202
|
+
return unless current_balances
|
203
|
+
|
204
|
+
current_balances[:equity] ||
|
205
|
+
current_balances[:accountEquity] ||
|
206
|
+
current_balances[:account_equity]
|
207
|
+
end
|
208
|
+
|
209
|
+
# Calculate total P&L for all positions
|
210
|
+
#
|
211
|
+
# @return [Float] Total profit/loss
|
212
|
+
def total_pnl
|
213
|
+
positions.sum { |position| position.unrealized_pnl || 0 }
|
214
|
+
end
|
215
|
+
|
216
|
+
# Calculate today's P&L for all positions
|
217
|
+
#
|
218
|
+
# @return [Float] Today's profit/loss
|
219
|
+
def todays_pnl
|
220
|
+
positions.sum { |position| position.day_pnl || 0 }
|
221
|
+
end
|
222
|
+
|
223
|
+
# Get positions filtered by asset type
|
224
|
+
#
|
225
|
+
# @param asset_type [String, Symbol] The asset type (e.g., :equity, :option)
|
226
|
+
# @return [Array<Schwab::Resources::Position>] Filtered positions
|
227
|
+
def positions_by_type(asset_type)
|
228
|
+
type_str = asset_type.to_s.upcase
|
229
|
+
positions.select do |position|
|
230
|
+
position.asset_type == type_str ||
|
231
|
+
position[:assetType] == type_str ||
|
232
|
+
position[:asset_type] == type_str
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
# Get equity positions
|
237
|
+
#
|
238
|
+
# @return [Array<Schwab::Resources::Position>] Equity positions
|
239
|
+
def equity_positions
|
240
|
+
positions_by_type(:equity)
|
241
|
+
end
|
242
|
+
|
243
|
+
# Get option positions
|
244
|
+
#
|
245
|
+
# @return [Array<Schwab::Resources::Position>] Option positions
|
246
|
+
def option_positions
|
247
|
+
positions_by_type(:option)
|
248
|
+
end
|
249
|
+
|
250
|
+
# Calculate total market value of positions
|
251
|
+
#
|
252
|
+
# @return [Float] Total market value
|
253
|
+
def total_market_value
|
254
|
+
positions.sum { |position| position.market_value || 0 }
|
255
|
+
end
|
256
|
+
|
257
|
+
# Check if account has positions
|
258
|
+
#
|
259
|
+
# @return [Boolean] True if account has positions
|
260
|
+
def has_positions?
|
261
|
+
!positions.empty?
|
262
|
+
end
|
263
|
+
|
264
|
+
# Get number of positions
|
265
|
+
#
|
266
|
+
# @return [Integer] Number of positions
|
267
|
+
def position_count
|
268
|
+
positions.size
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
end
|
@@ -0,0 +1,300 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "time"
|
4
|
+
require "date"
|
5
|
+
|
6
|
+
module Schwab
|
7
|
+
# Resource objects for wrapping API responses with convenient access patterns
|
8
|
+
module Resources
|
9
|
+
# Base class for resource objects that wrap API response hashes
|
10
|
+
# Provides Sawyer::Resource-like functionality for method access to hash data
|
11
|
+
#
|
12
|
+
# @example Creating a resource
|
13
|
+
# data = { name: "John", age: 30, address: { city: "NYC" } }
|
14
|
+
# resource = Schwab::Resources::Base.new(data)
|
15
|
+
# resource.name # => "John"
|
16
|
+
# resource[:age] # => 30
|
17
|
+
# resource.address.city # => "NYC"
|
18
|
+
class Base
|
19
|
+
class << self
|
20
|
+
# Define fields that should be coerced to specific types
|
21
|
+
# Subclasses can override this to specify their field types
|
22
|
+
def field_types
|
23
|
+
@field_types ||= {}
|
24
|
+
end
|
25
|
+
|
26
|
+
# Set field types for automatic coercion
|
27
|
+
def set_field_type(field, type)
|
28
|
+
field_types[field.to_sym] = type
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Initialize a new resource with data
|
33
|
+
#
|
34
|
+
# @param data [Hash] The data to wrap
|
35
|
+
# @param client [Schwab::Client, nil] Optional client for API calls
|
36
|
+
def initialize(data = {}, client = nil)
|
37
|
+
@data = data || {}
|
38
|
+
@client = client
|
39
|
+
@_nested_resources = {}
|
40
|
+
end
|
41
|
+
|
42
|
+
# Access data via method calls
|
43
|
+
#
|
44
|
+
# @param method_name [Symbol] The method name
|
45
|
+
# @param args [Array] Method arguments
|
46
|
+
# @param block [Proc] Optional block
|
47
|
+
# @return [Object] The value from the data hash
|
48
|
+
def method_missing(method_name, *args, &block)
|
49
|
+
key = method_name.to_s
|
50
|
+
|
51
|
+
# Check for setter methods
|
52
|
+
if key.end_with?("=")
|
53
|
+
key = key[0...-1]
|
54
|
+
@data[key.to_sym] = args.first
|
55
|
+
@data[key] = args.first
|
56
|
+
return args.first
|
57
|
+
end
|
58
|
+
|
59
|
+
# Try symbol key first, then string key
|
60
|
+
if @data.key?(method_name)
|
61
|
+
wrap_value(@data[method_name], method_name)
|
62
|
+
elsif @data.key?(key)
|
63
|
+
wrap_value(@data[key], key.to_sym)
|
64
|
+
else
|
65
|
+
super
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Check if method exists
|
70
|
+
#
|
71
|
+
# @param method_name [Symbol] The method name
|
72
|
+
# @param include_private [Boolean] Include private methods
|
73
|
+
# @return [Boolean] True if method exists
|
74
|
+
def respond_to_missing?(method_name, include_private = false)
|
75
|
+
key = method_name.to_s
|
76
|
+
is_setter = key.end_with?("=")
|
77
|
+
key = key[0...-1] if is_setter
|
78
|
+
|
79
|
+
# Always respond to setters, or check if key exists for getters
|
80
|
+
is_setter || @data.key?(method_name) || @data.key?(key.to_sym) || @data.key?(key) || super
|
81
|
+
end
|
82
|
+
|
83
|
+
# Hash-style access to data
|
84
|
+
#
|
85
|
+
# @param key [Symbol, String] The key to access
|
86
|
+
# @return [Object] The value
|
87
|
+
def [](key)
|
88
|
+
value = @data[key.to_sym] || @data[key.to_s]
|
89
|
+
wrap_value(value, key.to_sym)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Hash-style setter
|
93
|
+
#
|
94
|
+
# @param key [Symbol, String] The key to set
|
95
|
+
# @param value [Object] The value to set
|
96
|
+
def []=(key, value)
|
97
|
+
@data[key.to_sym] = value
|
98
|
+
@data[key.to_s] = value
|
99
|
+
end
|
100
|
+
|
101
|
+
# Check if key exists
|
102
|
+
#
|
103
|
+
# @param key [Symbol, String] The key to check
|
104
|
+
# @return [Boolean] True if key exists
|
105
|
+
def key?(key)
|
106
|
+
@data.key?(key.to_sym) || @data.key?(key.to_s)
|
107
|
+
end
|
108
|
+
alias_method :has_key?, :key?
|
109
|
+
|
110
|
+
# Get all keys
|
111
|
+
#
|
112
|
+
# @return [Array] Array of keys
|
113
|
+
def keys
|
114
|
+
@data.keys
|
115
|
+
end
|
116
|
+
|
117
|
+
# Convert to hash
|
118
|
+
#
|
119
|
+
# @return [Hash] The underlying data hash
|
120
|
+
def to_h
|
121
|
+
@data
|
122
|
+
end
|
123
|
+
alias_method :to_hash, :to_h
|
124
|
+
|
125
|
+
# Get attributes as hash
|
126
|
+
#
|
127
|
+
# @return [Hash] The underlying data hash
|
128
|
+
def attributes
|
129
|
+
@data
|
130
|
+
end
|
131
|
+
|
132
|
+
# Inspect the resource
|
133
|
+
#
|
134
|
+
# @return [String] String representation
|
135
|
+
def inspect
|
136
|
+
"#<#{self.class.name} #{@data.inspect}>"
|
137
|
+
end
|
138
|
+
|
139
|
+
# Convert to string
|
140
|
+
#
|
141
|
+
# @return [String] String representation
|
142
|
+
def to_s
|
143
|
+
@data.to_s
|
144
|
+
end
|
145
|
+
|
146
|
+
# Equality comparison
|
147
|
+
#
|
148
|
+
# @param other [Object] Object to compare
|
149
|
+
# @return [Boolean] True if equal
|
150
|
+
def ==(other)
|
151
|
+
case other
|
152
|
+
when self.class
|
153
|
+
@data == other.to_h
|
154
|
+
when Hash
|
155
|
+
@data == other
|
156
|
+
else
|
157
|
+
false
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Iterate over data
|
162
|
+
#
|
163
|
+
# @yield [key, value] Yields each key-value pair
|
164
|
+
def each(&block)
|
165
|
+
@data.each(&block)
|
166
|
+
end
|
167
|
+
|
168
|
+
# Check if resource has no data
|
169
|
+
#
|
170
|
+
# @return [Boolean] True if empty
|
171
|
+
def empty?
|
172
|
+
@data.empty?
|
173
|
+
end
|
174
|
+
|
175
|
+
protected
|
176
|
+
|
177
|
+
# Get the client instance
|
178
|
+
attr_reader :client
|
179
|
+
|
180
|
+
private
|
181
|
+
|
182
|
+
# Wrap nested hashes in resource objects and coerce types
|
183
|
+
#
|
184
|
+
# @param value [Object] The value to wrap
|
185
|
+
# @param field_name [Symbol, nil] The field name for type coercion
|
186
|
+
# @return [Object] The wrapped and coerced value
|
187
|
+
def wrap_value(value, field_name = nil)
|
188
|
+
# First apply type coercion if field type is defined
|
189
|
+
if field_name && self.class.field_types[field_name.to_sym]
|
190
|
+
value = coerce_value(value, self.class.field_types[field_name.to_sym])
|
191
|
+
end
|
192
|
+
|
193
|
+
case value
|
194
|
+
when Hash
|
195
|
+
# Cache nested resources to maintain object identity
|
196
|
+
@_nested_resources[value.object_id] ||= self.class.new(value, @client)
|
197
|
+
when Array
|
198
|
+
value.map { |item| wrap_value(item) }
|
199
|
+
else
|
200
|
+
value
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# Coerce a value to a specific type
|
205
|
+
#
|
206
|
+
# @param value [Object] The value to coerce
|
207
|
+
# @param type [Symbol, Class] The target type
|
208
|
+
# @return [Object] The coerced value
|
209
|
+
def coerce_value(value, type)
|
210
|
+
return if value.nil?
|
211
|
+
|
212
|
+
case type
|
213
|
+
when :time, Time
|
214
|
+
coerce_to_time(value)
|
215
|
+
when :date, Date
|
216
|
+
coerce_to_date(value)
|
217
|
+
when :datetime, DateTime
|
218
|
+
coerce_to_datetime(value)
|
219
|
+
when :integer, Integer
|
220
|
+
value.to_i
|
221
|
+
when :float, Float
|
222
|
+
value.to_f
|
223
|
+
when :decimal, BigDecimal
|
224
|
+
require "bigdecimal"
|
225
|
+
BigDecimal(value.to_s)
|
226
|
+
when :boolean
|
227
|
+
coerce_to_boolean(value)
|
228
|
+
when :symbol, Symbol
|
229
|
+
value.to_sym
|
230
|
+
else
|
231
|
+
value
|
232
|
+
end
|
233
|
+
rescue StandardError
|
234
|
+
value # Return original value if coercion fails
|
235
|
+
end
|
236
|
+
|
237
|
+
# Coerce to Time
|
238
|
+
def coerce_to_time(value)
|
239
|
+
case value
|
240
|
+
when Time
|
241
|
+
value
|
242
|
+
when Date, DateTime
|
243
|
+
value.to_time
|
244
|
+
when String
|
245
|
+
Time.parse(value)
|
246
|
+
when Integer, Float
|
247
|
+
# Assume milliseconds timestamp if large number
|
248
|
+
value > 9999999999 ? Time.at(value / 1000.0) : Time.at(value)
|
249
|
+
else
|
250
|
+
value
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
# Coerce to Date
|
255
|
+
def coerce_to_date(value)
|
256
|
+
case value
|
257
|
+
when Date
|
258
|
+
value
|
259
|
+
when Time, DateTime
|
260
|
+
value.to_date
|
261
|
+
when String
|
262
|
+
Date.parse(value)
|
263
|
+
else
|
264
|
+
value
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
# Coerce to DateTime
|
269
|
+
def coerce_to_datetime(value)
|
270
|
+
case value
|
271
|
+
when DateTime, Time
|
272
|
+
value
|
273
|
+
when Date
|
274
|
+
value.to_time
|
275
|
+
when String
|
276
|
+
Time.parse(value)
|
277
|
+
when Integer, Float
|
278
|
+
# Assume milliseconds timestamp if large number
|
279
|
+
value > 9999999999 ? Time.at(value / 1000.0) : Time.at(value)
|
280
|
+
else
|
281
|
+
value
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
# Coerce to boolean
|
286
|
+
def coerce_to_boolean(value)
|
287
|
+
case value
|
288
|
+
when TrueClass, FalseClass
|
289
|
+
value
|
290
|
+
when String
|
291
|
+
value.downcase == "true" || value == "1"
|
292
|
+
when Integer
|
293
|
+
value == 1
|
294
|
+
else
|
295
|
+
!!value
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|