treaty 0.12.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c10e0241b816115758a4383e524fc0f9cfb6738e95f4fddff5a1b32eb114c719
4
- data.tar.gz: 4a5bfe17af7e6efb87975770aed1a3a67fb817aeee13cdbd05c88c187407efe0
3
+ metadata.gz: 600a422c9ddcdff83b3ec4cde1c8124adb8ae52e1c1d88744d8474bc4afa8c68
4
+ data.tar.gz: dd425b19d451b34f46b07747586f35ba7df4dcac9c554a481ac5dd27e1fe2141
5
5
  SHA512:
6
- metadata.gz: 12b23780c22b9987d317323e723c81d5be07ff2099e74a6cf90c4eab5bb4d0d74a6f0c8b05f11f7800daf16c02d5dd7c317c2d7df770ad99c1638933e94b6297
7
- data.tar.gz: 4074fa86260a0f9e79adeaf6b41a61f70f5e568e7cbd6592b4eb8c19c270c954753db941be3490ce40944d1edc8cf9445caf75ef1de0b18e65e7939ad6ffd191
6
+ metadata.gz: 1739edd5d1c38fe70fc6ab892b06ec15df3a665724df68c95f351675dae4e8c6a00368d65489bbfde0900a67841b9aad1e7eb57f81b948248ae902755f485519
7
+ data.tar.gz: 627d321a706c846e3249afb9e97f4ceb48573af52fcf962459e38316ed76ed7d979c4fd0fcffa8146cff5e7cfeab36b4cf391f9bfb3d7c82ca8de3cd6c5ea439
@@ -49,6 +49,17 @@ en:
49
49
  as:
50
50
  invalid_type: "Option 'as' for attribute '%{attribute}' must be a Symbol. Got: %{type}"
51
51
 
52
+ transform:
53
+ invalid_type: "Option 'transform' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
54
+ execution_error: "Transform failed for attribute '%{attribute}': %{error}"
55
+
56
+ cast:
57
+ invalid_type: "Option 'cast' for attribute '%{attribute}' must be a Symbol. Got: %{type}"
58
+ source_not_supported: "Option 'cast' for attribute '%{attribute}' cannot be used with type '%{source_type}'. Casting is only supported for: %{allowed}"
59
+ target_not_supported: "Option 'cast' for attribute '%{attribute}' cannot cast to '%{target_type}'. Supported target types: %{allowed}"
60
+ conversion_not_supported: "Option 'cast' for attribute '%{attribute}' does not support conversion from '%{from}' to '%{to}'"
61
+ conversion_error: "Cast failed for attribute '%{attribute}' from '%{from}' to '%{to}'. Value: '%{value}'. Error: %{error}"
62
+
52
63
  # Attribute builder DSL
53
64
  builder:
54
65
  not_implemented: "%{class} must implement #create_attribute"
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Attribute
5
+ module Option
6
+ module Modifiers
7
+ # Converts attribute values between different types automatically.
8
+ #
9
+ # ## Usage Examples
10
+ #
11
+ # Simple mode:
12
+ # string :created_at, cast: :datetime
13
+ # datetime :timestamp, cast: :string
14
+ # integer :active, cast: :boolean
15
+ #
16
+ # Advanced mode with custom error message:
17
+ # string :created_at, cast: {
18
+ # to: :datetime,
19
+ # message: "Invalid date format"
20
+ # }
21
+ #
22
+ # ## Use Cases
23
+ #
24
+ # 1. **Request type conversion**:
25
+ # ```ruby
26
+ # request do
27
+ # string :created_at, cast: :datetime
28
+ # end
29
+ # # Input: { created_at: "2024-01-15T10:30:00Z" }
30
+ # # Service receives: { created_at: DateTime object }
31
+ # ```
32
+ #
33
+ # 2. **Response type conversion**:
34
+ # ```ruby
35
+ # response 200 do
36
+ # datetime :created_at, cast: :string
37
+ # end
38
+ # # Service returns: { created_at: DateTime object }
39
+ # # Output: { created_at: "2024-01-15T10:30:00Z" }
40
+ # ```
41
+ #
42
+ # 3. **Unix timestamp conversion**:
43
+ # ```ruby
44
+ # integer :timestamp, cast: :datetime
45
+ # datetime :created_at, cast: :integer
46
+ # ```
47
+ #
48
+ # ## Supported Conversions
49
+ #
50
+ # ### From Integer
51
+ # - integer -> string: Converts to string representation
52
+ # - integer -> boolean: 0 = false, non-zero = true
53
+ # - integer -> datetime: Treats as Unix timestamp
54
+ #
55
+ # ### From String
56
+ # - string -> integer: Parses integer from string
57
+ # - string -> boolean: Parses truthy/falsy strings (true/false, yes/no, 1/0, on/off)
58
+ # - string -> datetime: Parses datetime string (ISO8601, RFC3339, etc.)
59
+ #
60
+ # ### From Boolean
61
+ # - boolean -> string: Converts to "true" or "false"
62
+ # - boolean -> integer: true = 1, false = 0
63
+ #
64
+ # ### From DateTime
65
+ # - datetime -> string: Converts to ISO8601 format
66
+ # - datetime -> integer: Converts to Unix timestamp
67
+ #
68
+ # ## Important Notes
69
+ #
70
+ # - Cast option only works with scalar types (integer, string, boolean, datetime)
71
+ # - Array and Object types are not supported for casting
72
+ # - Casting to the same type is allowed (no-op)
73
+ # - Nil values are not transformed (handled by RequiredValidator)
74
+ # - All conversion errors are caught and re-raised as Validation errors
75
+ #
76
+ # ## Error Handling
77
+ #
78
+ # If conversion fails (e.g., invalid date string, non-numeric string to integer),
79
+ # the error is caught and converted to a Treaty::Exceptions::Validation error.
80
+ #
81
+ # ## Advanced Mode
82
+ #
83
+ # Schema format: `{ to: :target_type, message: "Custom error" }`
84
+ # Note: Uses `:to` key instead of the default `:is` key.
85
+ class CastModifier < Treaty::Attribute::Option::Base
86
+ # Types that support casting (scalar types only)
87
+ ALLOWED_CAST_TYPES = %i[integer string boolean datetime].freeze
88
+
89
+ # Validates that cast option is correctly configured
90
+ #
91
+ # @raise [Treaty::Exceptions::Validation] If cast configuration is invalid
92
+ # @return [void]
93
+ def validate_schema! # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
94
+ # If option_schema is nil, cast is not used for this attribute
95
+ return if @option_schema.nil?
96
+
97
+ target_type = option_value
98
+
99
+ # Validate that target type is a Symbol
100
+ unless target_type.is_a?(Symbol)
101
+ raise Treaty::Exceptions::Validation,
102
+ I18n.t(
103
+ "treaty.attributes.modifiers.cast.invalid_type",
104
+ attribute: @attribute_name,
105
+ type: target_type.class
106
+ )
107
+ end
108
+
109
+ # Validate that source type supports casting
110
+ unless ALLOWED_CAST_TYPES.include?(@attribute_type)
111
+ raise Treaty::Exceptions::Validation,
112
+ I18n.t(
113
+ "treaty.attributes.modifiers.cast.source_not_supported",
114
+ attribute: @attribute_name,
115
+ source_type: @attribute_type,
116
+ allowed: ALLOWED_CAST_TYPES.join(", ")
117
+ )
118
+ end
119
+
120
+ # Validate that target type is allowed
121
+ unless ALLOWED_CAST_TYPES.include?(target_type)
122
+ raise Treaty::Exceptions::Validation,
123
+ I18n.t(
124
+ "treaty.attributes.modifiers.cast.target_not_supported",
125
+ attribute: @attribute_name,
126
+ target_type:,
127
+ allowed: ALLOWED_CAST_TYPES.join(", ")
128
+ )
129
+ end
130
+
131
+ # Validate that conversion from source to target is supported
132
+ return if conversion_supported?(@attribute_type, target_type)
133
+
134
+ raise Treaty::Exceptions::Validation,
135
+ I18n.t(
136
+ "treaty.attributes.modifiers.cast.conversion_not_supported",
137
+ attribute: @attribute_name,
138
+ from: @attribute_type,
139
+ to: target_type
140
+ )
141
+ end
142
+
143
+ # Applies type conversion to the value
144
+ # Skips conversion for nil values (handled by RequiredValidator)
145
+ #
146
+ # @param value [Object] The current value
147
+ # @return [Object] Converted value
148
+ def transform_value(value) # rubocop:disable Metrics/MethodLength
149
+ return value if value.nil? # Cast doesn't modify nil, required validator handles it.
150
+
151
+ target_type = option_value
152
+ conversion_lambda = conversion_matrix.dig(@attribute_type, target_type)
153
+
154
+ # Call conversion lambda
155
+ conversion_lambda.call(value:)
156
+ rescue StandardError => e
157
+ attributes = {
158
+ attribute: @attribute_name,
159
+ from: @attribute_type,
160
+ to: target_type,
161
+ value:,
162
+ error: e.message
163
+ }
164
+
165
+ # Catch all exceptions from conversion execution
166
+ error_message = resolve_custom_message(**attributes) || I18n.t(
167
+ "treaty.attributes.modifiers.cast.conversion_error",
168
+ **attributes
169
+ )
170
+
171
+ raise Treaty::Exceptions::Validation, error_message
172
+ end
173
+
174
+ protected
175
+
176
+ # Override value_key to use :to instead of :is
177
+ # This makes advanced mode syntax: cast: { to: :datetime }
178
+ #
179
+ # @return [Symbol] The key :to
180
+ def value_key
181
+ :to
182
+ end
183
+
184
+ private
185
+
186
+ # Checks if conversion from source type to target type is supported
187
+ #
188
+ # @param from_type [Symbol] Source type
189
+ # @param to_type [Symbol] Target type
190
+ # @return [Boolean] True if conversion is supported
191
+ def conversion_supported?(from_type, to_type)
192
+ conversion_matrix.dig(from_type, to_type).present?
193
+ end
194
+
195
+ # Matrix of all supported type conversions
196
+ # Maps from_type => to_type => conversion_lambda
197
+ #
198
+ # @return [Hash] Conversion matrix
199
+ def conversion_matrix # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
200
+ @conversion_matrix ||= {
201
+ integer: {
202
+ integer: ->(value:) { value }, # No-op for same type
203
+ string: ->(value:) { value.to_s },
204
+ boolean: ->(value:) { value != 0 },
205
+ datetime: ->(value:) { Time.at(value) }
206
+ },
207
+ string: {
208
+ string: ->(value:) { value }, # No-op for same type
209
+ integer: ->(value:) { Integer(value) },
210
+ boolean: ->(value:) { parse_boolean(value) },
211
+ datetime: ->(value:) { DateTime.parse(value) }
212
+ },
213
+ boolean: {
214
+ boolean: ->(value:) { value }, # No-op for same type
215
+ string: ->(value:) { value.to_s },
216
+ integer: ->(value:) { value ? 1 : 0 }
217
+ },
218
+ datetime: {
219
+ datetime: ->(value:) { value }, # No-op for same type
220
+ string: ->(value:) { value.iso8601 },
221
+ integer: ->(value:) { value.to_i }
222
+ }
223
+ }
224
+ end
225
+
226
+ # Parses a string value into a boolean
227
+ # Recognizes: true/false, yes/no, 1/0, on/off (case-insensitive)
228
+ #
229
+ # @param value [String] The string value to parse
230
+ # @return [Boolean] Parsed boolean value
231
+ # @raise [ArgumentError] If string is not a recognized boolean value
232
+ def parse_boolean(value)
233
+ normalized = value.to_s.downcase.strip
234
+
235
+ return true if %w[true 1 yes on].include?(normalized)
236
+ return false if %w[false 0 no off].include?(normalized)
237
+
238
+ raise ArgumentError, "Cannot convert '#{value}' to boolean"
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Attribute
5
+ module Option
6
+ module Modifiers
7
+ # Transforms attribute values using custom lambda functions.
8
+ #
9
+ # ## Usage Examples
10
+ #
11
+ # Simple mode:
12
+ # integer :amount, transform: ->(value:) { value * 100 }
13
+ # string :title, transform: ->(value:) { value.strip.upcase }
14
+ #
15
+ # Advanced mode with custom error message:
16
+ # integer :amount, transform: {
17
+ # is: ->(value:) { value * 100 },
18
+ # message: "Failed to transform amount"
19
+ # }
20
+ #
21
+ # ## Use Cases
22
+ #
23
+ # 1. **Request transformation**:
24
+ # ```ruby
25
+ # request do
26
+ # integer :amount_cents, transform: ->(value:) { value * 100 }
27
+ # end
28
+ # # Input: { amount_cents: 10 }
29
+ # # Service receives: { amount_cents: 1000 }
30
+ # ```
31
+ #
32
+ # 2. **Response transformation**:
33
+ # ```ruby
34
+ # response 200 do
35
+ # string :title, transform: ->(value:) { value.titleize }
36
+ # end
37
+ # # Service returns: { title: "hello world" }
38
+ # # Output: { title: "Hello World" }
39
+ # ```
40
+ #
41
+ # 3. **Complex transformations**:
42
+ # ```ruby
43
+ # string :email, transform: ->(value:) { value.downcase.strip }
44
+ # datetime :timestamp, transform: ->(value:) { value.iso8601 }
45
+ # ```
46
+ #
47
+ # ## Important Notes
48
+ #
49
+ # - Lambda must accept named argument `value:`
50
+ # - All exceptions raised in lambda are caught and re-raised as Validation errors
51
+ # - Transformation is applied during Phase 3 (after validation)
52
+ # - Can be combined with other options (required, default, as, etc.)
53
+ #
54
+ # ## Error Handling
55
+ #
56
+ # If the lambda raises any exception, it's caught and converted to a
57
+ # Treaty::Exceptions::Validation with appropriate error message.
58
+ #
59
+ # ## Advanced Mode
60
+ #
61
+ # Schema format: `{ is: lambda, message: nil }`
62
+ class TransformModifier < Treaty::Attribute::Option::Base
63
+ # Validates that transform value is a lambda
64
+ #
65
+ # @raise [Treaty::Exceptions::Validation] If transform is not a Proc/lambda
66
+ # @return [void]
67
+ def validate_schema!
68
+ transform_lambda = option_value
69
+
70
+ return if transform_lambda.respond_to?(:call)
71
+
72
+ raise Treaty::Exceptions::Validation,
73
+ I18n.t(
74
+ "treaty.attributes.modifiers.transform.invalid_type",
75
+ attribute: @attribute_name,
76
+ type: transform_lambda.class
77
+ )
78
+ end
79
+
80
+ # Applies transformation to the value using the provided lambda
81
+ # Catches all exceptions and re-raises as Validation errors
82
+ # Skips transformation for nil values (handled by RequiredValidator)
83
+ #
84
+ # @param value [Object] The current value
85
+ # @return [Object] Transformed value
86
+ def transform_value(value) # rubocop:disable Metrics/MethodLength
87
+ return value if value.nil? # Transform doesn't modify nil, required validator handles it.
88
+
89
+ transform_lambda = option_value
90
+
91
+ # Call lambda with named argument
92
+ transform_lambda.call(value:)
93
+ rescue StandardError => e
94
+ attributes = {
95
+ attribute: @attribute_name,
96
+ error: e.message
97
+ }
98
+
99
+ # Catch all exceptions from lambda execution
100
+ error_message = resolve_custom_message(**attributes) || I18n.t(
101
+ "treaty.attributes.modifiers.transform.execution_error",
102
+ **attributes
103
+ )
104
+
105
+ raise Treaty::Exceptions::Validation, error_message
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -27,6 +27,8 @@ module Treaty
27
27
  #
28
28
  # - `:as` → AsModifier - Renames attributes
29
29
  # - `:default` → DefaultModifier - Provides default values
30
+ # - `:transform` → TransformModifier - Transforms values using custom lambdas
31
+ # - `:cast` → CastModifier - Converts values between types automatically
30
32
  #
31
33
  # ## Auto-Registration
32
34
  #
@@ -81,6 +83,8 @@ module Treaty
81
83
  def register_modifiers!
82
84
  Registry.register(:as, Modifiers::AsModifier, category: :modifier)
83
85
  Registry.register(:default, Modifiers::DefaultModifier, category: :modifier)
86
+ Registry.register(:transform, Modifiers::TransformModifier, category: :modifier)
87
+ Registry.register(:cast, Modifiers::CastModifier, category: :modifier)
84
88
  end
85
89
  end
86
90
  end
@@ -70,7 +70,8 @@ module Treaty
70
70
  OPTION_KEY_MAPPING = {
71
71
  in: { advanced_key: :inclusion, value_key: :in },
72
72
  as: { advanced_key: :as, value_key: :is },
73
- default: { advanced_key: :default, value_key: :is }
73
+ default: { advanced_key: :default, value_key: :is },
74
+ cast: { advanced_key: :cast, value_key: :to }
74
75
  }.freeze
75
76
  private_constant :OPTION_KEY_MAPPING
76
77
 
@@ -138,8 +138,7 @@ module Treaty
138
138
  def transform(value)
139
139
  value.each_with_index.map do |item, index|
140
140
  if simple_array?
141
- validate_simple_element(item, index)
142
- item
141
+ transform_simple_element(item, index)
143
142
  else
144
143
  transform_array_item(item, index)
145
144
  end
@@ -156,19 +155,21 @@ module Treaty
156
155
  attribute.collection_of_attributes.first.name == SELF_OBJECT
157
156
  end
158
157
 
159
- # Validates a simple array element (primitive value)
158
+ # Transforms a simple array element (primitive value)
159
+ # Validates and applies transformations to the element
160
160
  #
161
- # @param item [Object] Array element to validate
161
+ # @param item [Object] Array element to transform
162
162
  # @param index [Integer] Element index for error messages
163
163
  # @raise [Treaty::Exceptions::Validation] If validation fails
164
- # @return [void]
165
- def validate_simple_element(item, index) # rubocop:disable Metrics/MethodLength
164
+ # @return [Object] Transformed element value
165
+ def transform_simple_element(item, index) # rubocop:disable Metrics/MethodLength
166
166
  self_attr = attribute.collection_of_attributes.first
167
167
  validator = AttributeValidator.new(self_attr)
168
168
  validator.validate_schema!
169
169
 
170
170
  begin
171
171
  validator.validate_value!(item)
172
+ validator.transform_value(item)
172
173
  rescue Treaty::Exceptions::Validation => e
173
174
  raise Treaty::Exceptions::Validation,
174
175
  I18n.t(
@@ -3,7 +3,7 @@
3
3
  module Treaty
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 12
6
+ MINOR = 13
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: treaty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Sokolov
@@ -156,7 +156,9 @@ files:
156
156
  - lib/treaty/attribute/helper_mapper.rb
157
157
  - lib/treaty/attribute/option/base.rb
158
158
  - lib/treaty/attribute/option/modifiers/as_modifier.rb
159
+ - lib/treaty/attribute/option/modifiers/cast_modifier.rb
159
160
  - lib/treaty/attribute/option/modifiers/default_modifier.rb
161
+ - lib/treaty/attribute/option/modifiers/transform_modifier.rb
160
162
  - lib/treaty/attribute/option/registry.rb
161
163
  - lib/treaty/attribute/option/registry_initializer.rb
162
164
  - lib/treaty/attribute/option/validators/format_validator.rb