Skip to content

Commit fcedaaa

Browse files
jar-stripeclaude
andauthored
Extract V2TypeCoercion module for bidirectional field encoding (#1926)
* Extract V2TypeCoercion module for bidirectional field encoding Consolidates request-side (encode: native → wire) and response-side (decode: wire → native) type coercion into a shared module that handles all V2 encoding kinds: int64_string, decimal_string, object, array, nullable, and discriminated_union. Previously, request_params.rb had its own coercion implementation and stripe_object.rb only handled decimal_string with a flat equality check. This left int64_string fields uncoerced on the response side (returning String instead of Integer) and didn't support nested schemas like {kind: :nullable, inner: :decimal_string}. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * Scope module to existing encoding kinds only Remove nullable and discriminated_union cases — those belong in the DU coercion branch, not this base extraction. The module should only contain what already exists on master: int64_string, decimal_string, object, array. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Committed-By-Agent: claude --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 61051e1 commit fcedaaa

6 files changed

Lines changed: 384 additions & 59 deletions

File tree

lib/stripe.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
require "stripe/object_types"
3535
require "stripe/event_types"
3636
require "stripe/request_options"
37+
require "stripe/v2_type_coercion"
3738
require "stripe/request_params"
3839
require "stripe/stripe_context"
3940
require "stripe/util"

lib/stripe/request_params.rb

Lines changed: 1 addition & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# frozen_string_literal: true
22
# typed: true
33

4-
require "bigdecimal"
5-
64
module Stripe
75
# For internal use only. Does not provide a stable API and may be broken
86
# with future non-major changes.
@@ -64,61 +62,8 @@ def self.field_encodings
6462
@field_encodings ||= {}
6563
end
6664

67-
# Recursively coerce a value based on its field encoding schema.
68-
# Handles :int64_string leaves, { kind: :object, fields: ... } nesting,
69-
# and { kind: :array, element: ... } for arrays.
7065
def self.coerce_value(value, encoding)
71-
return value if value.nil?
72-
73-
case encoding
74-
when :int64_string
75-
coerce_int64_string(value)
76-
when :decimal_string
77-
coerce_decimal_string(value)
78-
when Hash
79-
coerce_composite(value, encoding)
80-
else
81-
value
82-
end
83-
end
84-
85-
private_class_method def self.coerce_int64_string(value)
86-
case value
87-
when Integer then value.to_s
88-
when Array then value.map { |v| v.is_a?(Integer) ? v.to_s : v }
89-
else value
90-
end
91-
end
92-
93-
private_class_method def self.coerce_decimal_string(value)
94-
case value
95-
when BigDecimal then value.to_s("F")
96-
when Integer, Float then value.to_s
97-
when Array then value.map { |v| coerce_decimal_string(v) }
98-
else value
99-
end
100-
end
101-
102-
private_class_method def self.coerce_composite(value, encoding)
103-
case encoding[:kind]
104-
when :object
105-
coerce_object(value, encoding[:fields] || {})
106-
when :array
107-
return value unless value.is_a?(Array)
108-
109-
value.map { |v| coerce_value(v, encoding[:element]) }
110-
else
111-
value
112-
end
113-
end
114-
115-
private_class_method def self.coerce_object(value, fields_schema)
116-
return value unless value.is_a?(Hash)
117-
118-
value.each_with_object({}) do |(k, v), result|
119-
field_encoding = fields_schema[k.to_sym]
120-
result[k] = field_encoding ? coerce_value(v, field_encoding) : v
121-
end
66+
V2TypeCoercion.coerce_value(value, encoding, direction: :encode)
12267
end
12368

12469
# Coerce a plain Hash using this class's field_encodings.

lib/stripe/stripe_object.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,8 @@ def update_attributes(values, opts = {}, dirty: true)
157157
values.each do |k, v|
158158
add_accessors([k], values) unless metaclass.method_defined?(k.to_sym)
159159
@values[k] = convert_value_with_inner_types(k, v, opts)
160-
if self.class.field_encodings[k.to_sym] == :decimal_string && @values[k].is_a?(String)
161-
@values[k] = BigDecimal(@values[k])
162-
end
160+
encoding = self.class.field_encodings[k.to_sym]
161+
@values[k] = V2TypeCoercion.coerce_value(@values[k], encoding, direction: :decode) if encoding
163162
dirty_value!(@values[k]) if dirty
164163
@unsaved_values.add(k)
165164
end

lib/stripe/v2_type_coercion.rb

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# frozen_string_literal: true
2+
# typed: true
3+
4+
require "bigdecimal"
5+
6+
module Stripe
7+
# Shared V2 type coercion logic for encoding/decoding values between
8+
# native Ruby types and their wire representations.
9+
#
10+
# Used by RequestParams (encode: native → wire) and StripeObject (decode: wire → native).
11+
module V2TypeCoercion
12+
# Coerce a single value based on its field encoding schema.
13+
# direction: :encode (request: native → wire) or :decode (response: wire → native)
14+
module_function def coerce_value(value, encoding, direction:)
15+
return value if value.nil?
16+
17+
case encoding
18+
when :int64_string
19+
coerce_int64_string(value, direction)
20+
when :decimal_string
21+
coerce_decimal_string(value, direction)
22+
when Hash
23+
coerce_composite(value, encoding, direction)
24+
else
25+
value
26+
end
27+
end
28+
29+
# Coerce all fields in a hash according to a schema map.
30+
module_function def coerce_fields(hash, schema, direction:)
31+
return hash unless hash.is_a?(Hash)
32+
return hash if schema.nil? || schema.empty?
33+
34+
hash.each_with_object({}) do |(k, v), result|
35+
field_encoding = schema[k.to_sym]
36+
result[k] = field_encoding ? coerce_value(v, field_encoding, direction: direction) : v
37+
end
38+
end
39+
40+
# --- leaf coercions ---
41+
42+
module_function def coerce_int64_string(value, direction)
43+
case direction
44+
when :encode
45+
case value
46+
when Integer then value.to_s
47+
when Array then value.map { |v| v.is_a?(Integer) ? v.to_s : v }
48+
else value
49+
end
50+
when :decode
51+
case value
52+
when String then Kernel.Integer(value)
53+
when Array then value.map { |v| v.is_a?(String) ? Kernel.Integer(v) : v }
54+
else value
55+
end
56+
else
57+
Kernel.raise ArgumentError, "unknown direction: #{direction.inspect}"
58+
end
59+
end
60+
61+
module_function def coerce_decimal_string(value, direction)
62+
case direction
63+
when :encode
64+
case value
65+
when BigDecimal then value.to_s("F")
66+
when Integer, Float then value.to_s
67+
when Array then value.map { |v| coerce_decimal_string(v, direction) }
68+
else value
69+
end
70+
when :decode
71+
case value
72+
when String then Kernel.BigDecimal(value)
73+
when Array then value.map { |v| coerce_decimal_string(v, direction) }
74+
else value
75+
end
76+
else
77+
Kernel.raise ArgumentError, "unknown direction: #{direction.inspect}"
78+
end
79+
end
80+
81+
# --- composite coercions ---
82+
83+
module_function def coerce_composite(value, encoding, direction)
84+
case encoding[:kind]
85+
when :object
86+
coerce_object(value, encoding[:fields] || {}, direction)
87+
when :array
88+
return value unless value.is_a?(Array)
89+
90+
value.map { |v| coerce_value(v, encoding[:element], direction: direction) }
91+
else
92+
value
93+
end
94+
end
95+
96+
module_function def coerce_object(value, fields_schema, direction)
97+
return value unless value.is_a?(Hash)
98+
99+
coerce_fields(value, fields_schema, direction: direction)
100+
end
101+
end
102+
end
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# frozen_string_literal: true
2+
3+
require File.expand_path("../test_helper", __dir__)
4+
5+
module Stripe
6+
class StripeObjectInt64Test < Test::Unit::TestCase
7+
# A test resource with an int64_string-encoded field.
8+
class Int64Resource < Stripe::StripeObject
9+
def self.field_encodings
10+
{ amount: :int64_string }
11+
end
12+
end
13+
14+
# A nested inner class that has its own field_encodings.
15+
class InnerDetail < Stripe::StripeObject
16+
def self.field_encodings
17+
{ quantity: :int64_string }
18+
end
19+
end
20+
21+
# A parent resource referencing the inner class via inner_class_types.
22+
# This demonstrates that nested coercion works through each StripeObject
23+
# applying its own field_encodings during update_attributes (not through
24+
# V2TypeCoercion recursing into StripeObjects).
25+
class ParentResource < Stripe::StripeObject
26+
def self.field_encodings
27+
{ total: :int64_string }
28+
end
29+
30+
def self.inner_class_types
31+
{ detail: InnerDetail }
32+
end
33+
end
34+
35+
context "response-side int64_string coercion via construct_from" do
36+
should "deserialize a string field to Integer" do
37+
obj = Int64Resource.construct_from(id: "obj_1", amount: "42")
38+
assert_equal 42, obj.amount
39+
assert_instance_of Integer, obj.amount
40+
end
41+
42+
should "handle large int64 values" do
43+
obj = Int64Resource.construct_from(id: "obj_2", amount: "9223372036854775807")
44+
assert_equal 9_223_372_036_854_775_807, obj.amount
45+
end
46+
47+
should "preserve negative values" do
48+
obj = Int64Resource.construct_from(id: "obj_3", amount: "-100")
49+
assert_equal(-100, obj.amount)
50+
end
51+
52+
should "preserve zero" do
53+
obj = Int64Resource.construct_from(id: "obj_4", amount: "0")
54+
assert_equal 0, obj.amount
55+
assert_instance_of Integer, obj.amount
56+
end
57+
58+
should "pass through nil without error" do
59+
obj = Int64Resource.construct_from(id: "obj_5", amount: nil)
60+
assert_nil obj.amount
61+
end
62+
63+
should "leave non-encoded fields unaffected" do
64+
obj = Int64Resource.construct_from(id: "obj_6", amount: "10", name: "test")
65+
assert_equal "test", obj.name
66+
end
67+
end
68+
69+
context "nested resource coercion (each layer applies its own field_encodings)" do
70+
should "coerce both parent and nested inner-class fields" do
71+
obj = ParentResource.construct_from(
72+
id: "parent_1",
73+
total: "500",
74+
detail: { quantity: "25", label: "items" }
75+
)
76+
77+
assert_equal 500, obj.total
78+
assert_instance_of Integer, obj.total
79+
80+
assert_equal 25, obj.detail.quantity
81+
assert_instance_of Integer, obj.detail.quantity
82+
83+
assert_equal "items", obj.detail.label
84+
end
85+
86+
should "handle nil nested object" do
87+
obj = ParentResource.construct_from(id: "parent_2", total: "100", detail: nil)
88+
assert_equal 100, obj.total
89+
assert_nil obj.detail
90+
end
91+
end
92+
end
93+
end

0 commit comments

Comments
 (0)