treaty 0.17.0 → 0.19.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/config/locales/en.yml +6 -2
  4. data/lib/treaty/engine.rb +1 -1
  5. data/lib/treaty/{attribute/entity → entity/attribute}/attribute.rb +4 -4
  6. data/lib/treaty/entity/attribute/base.rb +184 -0
  7. data/lib/treaty/entity/attribute/builder/base.rb +275 -0
  8. data/lib/treaty/entity/attribute/collection.rb +67 -0
  9. data/lib/treaty/entity/attribute/dsl.rb +92 -0
  10. data/lib/treaty/entity/attribute/helper_mapper.rb +74 -0
  11. data/lib/treaty/entity/attribute/option/base.rb +190 -0
  12. data/lib/treaty/entity/attribute/option/conditionals/base.rb +92 -0
  13. data/lib/treaty/entity/attribute/option/conditionals/if_conditional.rb +136 -0
  14. data/lib/treaty/entity/attribute/option/conditionals/unless_conditional.rb +153 -0
  15. data/lib/treaty/entity/attribute/option/modifiers/as_modifier.rb +93 -0
  16. data/lib/treaty/entity/attribute/option/modifiers/cast_modifier.rb +285 -0
  17. data/lib/treaty/entity/attribute/option/modifiers/computed_modifier.rb +128 -0
  18. data/lib/treaty/entity/attribute/option/modifiers/default_modifier.rb +105 -0
  19. data/lib/treaty/entity/attribute/option/modifiers/transform_modifier.rb +114 -0
  20. data/lib/treaty/entity/attribute/option/registry.rb +157 -0
  21. data/lib/treaty/entity/attribute/option/registry_initializer.rb +117 -0
  22. data/lib/treaty/entity/attribute/option/validators/format_validator.rb +222 -0
  23. data/lib/treaty/entity/attribute/option/validators/inclusion_validator.rb +94 -0
  24. data/lib/treaty/entity/attribute/option/validators/required_validator.rb +100 -0
  25. data/lib/treaty/entity/attribute/option/validators/type_validator.rb +219 -0
  26. data/lib/treaty/entity/attribute/option_normalizer.rb +168 -0
  27. data/lib/treaty/entity/attribute/option_orchestrator.rb +192 -0
  28. data/lib/treaty/entity/attribute/validation/attribute_validator.rb +147 -0
  29. data/lib/treaty/entity/attribute/validation/base.rb +76 -0
  30. data/lib/treaty/entity/attribute/validation/nested_array_validator.rb +207 -0
  31. data/lib/treaty/entity/attribute/validation/nested_object_validator.rb +105 -0
  32. data/lib/treaty/entity/attribute/validation/nested_transformer.rb +432 -0
  33. data/lib/treaty/entity/attribute/validation/orchestrator/base.rb +262 -0
  34. data/lib/treaty/entity/base.rb +90 -0
  35. data/lib/treaty/entity/builder.rb +44 -0
  36. data/lib/treaty/{info/entity → entity/info}/builder.rb +8 -8
  37. data/lib/treaty/{info/entity → entity/info}/dsl.rb +2 -2
  38. data/lib/treaty/{info/entity → entity/info}/result.rb +2 -2
  39. data/lib/treaty/entity.rb +7 -75
  40. data/lib/treaty/request/attribute/attribute.rb +1 -1
  41. data/lib/treaty/request/attribute/builder.rb +24 -1
  42. data/lib/treaty/request/entity.rb +1 -1
  43. data/lib/treaty/request/factory.rb +6 -6
  44. data/lib/treaty/request/validator.rb +1 -1
  45. data/lib/treaty/response/attribute/attribute.rb +1 -1
  46. data/lib/treaty/response/attribute/builder.rb +24 -1
  47. data/lib/treaty/response/entity.rb +1 -1
  48. data/lib/treaty/response/factory.rb +6 -6
  49. data/lib/treaty/response/validator.rb +1 -1
  50. data/lib/treaty/version.rb +1 -1
  51. metadata +35 -34
  52. data/lib/treaty/attribute/base.rb +0 -182
  53. data/lib/treaty/attribute/builder/base.rb +0 -143
  54. data/lib/treaty/attribute/collection.rb +0 -65
  55. data/lib/treaty/attribute/dsl.rb +0 -90
  56. data/lib/treaty/attribute/entity/builder.rb +0 -23
  57. data/lib/treaty/attribute/helper_mapper.rb +0 -72
  58. data/lib/treaty/attribute/option/base.rb +0 -188
  59. data/lib/treaty/attribute/option/conditionals/base.rb +0 -90
  60. data/lib/treaty/attribute/option/conditionals/if_conditional.rb +0 -134
  61. data/lib/treaty/attribute/option/conditionals/unless_conditional.rb +0 -151
  62. data/lib/treaty/attribute/option/modifiers/as_modifier.rb +0 -91
  63. data/lib/treaty/attribute/option/modifiers/cast_modifier.rb +0 -283
  64. data/lib/treaty/attribute/option/modifiers/computed_modifier.rb +0 -126
  65. data/lib/treaty/attribute/option/modifiers/default_modifier.rb +0 -103
  66. data/lib/treaty/attribute/option/modifiers/transform_modifier.rb +0 -112
  67. data/lib/treaty/attribute/option/registry.rb +0 -155
  68. data/lib/treaty/attribute/option/registry_initializer.rb +0 -115
  69. data/lib/treaty/attribute/option/validators/format_validator.rb +0 -220
  70. data/lib/treaty/attribute/option/validators/inclusion_validator.rb +0 -92
  71. data/lib/treaty/attribute/option/validators/required_validator.rb +0 -98
  72. data/lib/treaty/attribute/option/validators/type_validator.rb +0 -217
  73. data/lib/treaty/attribute/option_normalizer.rb +0 -166
  74. data/lib/treaty/attribute/option_orchestrator.rb +0 -190
  75. data/lib/treaty/attribute/validation/attribute_validator.rb +0 -145
  76. data/lib/treaty/attribute/validation/base.rb +0 -74
  77. data/lib/treaty/attribute/validation/nested_array_validator.rb +0 -205
  78. data/lib/treaty/attribute/validation/nested_object_validator.rb +0 -103
  79. data/lib/treaty/attribute/validation/nested_transformer.rb +0 -430
  80. data/lib/treaty/attribute/validation/orchestrator/base.rb +0 -260
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Entity
5
+ module Attribute
6
+ module Option
7
+ module Conditionals
8
+ # Conditionally excludes attributes based on runtime data evaluation.
9
+ #
10
+ # ## Usage Examples
11
+ #
12
+ # Basic usage with keyword arguments splat:
13
+ # array :tags, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
14
+ # integer :draft_views, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
15
+ #
16
+ # Named argument pattern:
17
+ # array :draft_notes, unless: ->(post:) { post[:published_at].present? }
18
+ # integer :edit_count, unless: ->(post:) { post[:published_at].present? }
19
+ #
20
+ # Complex conditions:
21
+ # string :internal_note, unless: (lambda do |**attributes|
22
+ # attributes.dig(:user, :role) == "admin" && attributes.dig(:post, :flagged)
23
+ # end)
24
+ #
25
+ # ## Use Cases
26
+ #
27
+ # 1. **Hide fields when published**:
28
+ # ```ruby
29
+ # response 200 do
30
+ # object :post do
31
+ # string :id
32
+ # string :title
33
+ # datetime :published_at, :optional
34
+ # integer :draft_views, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
35
+ # end
36
+ # end
37
+ # # If published_at is nil → draft_views is included in response
38
+ # # If published_at exists → draft_views is excluded
39
+ # ```
40
+ #
41
+ # 2. **Role-based field exclusion**:
42
+ # ```ruby
43
+ # response 200 do
44
+ # object :user do
45
+ # string :name
46
+ # string :internal_id, unless: ->(user:) { user[:role] == "public" }
47
+ # end
48
+ # end
49
+ # ```
50
+ #
51
+ # 3. **Nested attribute conditionals**:
52
+ # ```ruby
53
+ # object :post do
54
+ # string :title
55
+ # array :draft_notes, unless: ->(post:) { post[:published_at].present? } do
56
+ # string :_self
57
+ # end
58
+ # end
59
+ # ```
60
+ #
61
+ # ## Important Notes
62
+ #
63
+ # - Lambda receives raw data as named arguments
64
+ # - Lambda MUST return truthy/falsy value
65
+ # - If condition is true → attribute is completely omitted (OPPOSITE of `if`)
66
+ # - If condition is false → attribute is validated and transformed normally
67
+ # - All exceptions in lambda are caught and wrapped in Treaty::Exceptions::Validation
68
+ # - Does NOT support simple mode (unless: true) or advanced mode (unless: { is: ..., message: ... })
69
+ #
70
+ # ## Difference from `if` Option
71
+ #
72
+ # `unless` is the logical opposite of `if`:
73
+ # - `if` includes attribute when condition is TRUE
74
+ # - `unless` includes attribute when condition is FALSE
75
+ #
76
+ # ```ruby
77
+ # # These are equivalent:
78
+ # integer :rating, if: ->(**attributes) { attributes.dig(:post, :published_at).present? }
79
+ # integer :rating, unless: ->(**attributes) { attributes.dig(:post, :published_at).blank? }
80
+ #
81
+ # # These are also equivalent:
82
+ # integer :draft_views, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
83
+ # integer :draft_views, if: ->(**attributes) { attributes.dig(:post, :published_at).blank? }
84
+ # ```
85
+ #
86
+ # ## Error Handling
87
+ #
88
+ # If the lambda raises any exception, it's caught and converted to a
89
+ # Treaty::Exceptions::Validation with detailed error message including:
90
+ # - Attribute name
91
+ # - Original exception message
92
+ #
93
+ # ## Data Access Pattern
94
+ #
95
+ # The lambda receives the same data structure that the orchestrator processes.
96
+ # For nested attributes, you can access parent data using dig:
97
+ #
98
+ # ```ruby
99
+ # # For response with { post: { title: "...", published_at: "..." } }
100
+ # integer :draft_views, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
101
+ #
102
+ # # Alternative: named argument pattern
103
+ # integer :draft_views, unless: ->(post:) { post[:published_at].present? }
104
+ # ```
105
+ class UnlessConditional < Treaty::Entity::Attribute::Option::Conditionals::Base
106
+ # Validates that unless option is a callable (Proc/Lambda)
107
+ #
108
+ # @raise [Treaty::Exceptions::Validation] If unless is not a Proc/lambda
109
+ # @return [void]
110
+ def validate_schema!
111
+ conditional_lambda = @option_schema
112
+
113
+ return if conditional_lambda.respond_to?(:call)
114
+
115
+ raise Treaty::Exceptions::Validation,
116
+ I18n.t(
117
+ "treaty.attributes.conditionals.unless.invalid_type",
118
+ attribute: @attribute_name,
119
+ type: conditional_lambda.class
120
+ )
121
+ end
122
+
123
+ # Evaluates the conditional lambda with runtime data
124
+ # Returns boolean indicating if attribute should be processed
125
+ #
126
+ # @param data [Hash] Raw data from request/response/entity
127
+ # @raise [Treaty::Exceptions::Validation] If lambda execution fails
128
+ # @return [Boolean] True if attribute should be processed (when condition is FALSE), false to skip it
129
+ def evaluate_condition(data)
130
+ conditional_lambda = @option_schema
131
+
132
+ # Call lambda with raw data as named arguments
133
+ # The lambda can use **attributes or specific named args like post:
134
+ result = conditional_lambda.call(**data)
135
+
136
+ # Convert result to boolean and NEGATE it (opposite of if)
137
+ # unless includes attribute when condition is FALSE
138
+ !result
139
+ rescue StandardError => e
140
+ # Catch all exceptions from lambda execution
141
+ raise Treaty::Exceptions::Validation,
142
+ I18n.t(
143
+ "treaty.attributes.conditionals.unless.evaluation_error",
144
+ attribute: @attribute_name,
145
+ error: e.message
146
+ )
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Entity
5
+ module Attribute
6
+ module Option
7
+ module Modifiers
8
+ # Transforms attribute names during data processing.
9
+ #
10
+ # ## Usage Examples
11
+ #
12
+ # Simple mode:
13
+ # # Request: expects "handle", outputs as "value"
14
+ # string :handle, as: :value
15
+ #
16
+ # Advanced mode:
17
+ # string :handle, as: { is: :value, message: nil }
18
+ #
19
+ # ## Use Cases
20
+ #
21
+ # 1. **Request to Service mapping**:
22
+ # ```ruby
23
+ # request do
24
+ # string :user_id, as: :id
25
+ # end
26
+ # # Input: { user_id: "123" }
27
+ # # Service receives: { id: "123" }
28
+ # ```
29
+ #
30
+ # 2. **Service to Response mapping**:
31
+ # ```ruby
32
+ # response 200 do
33
+ # string :id, as: :user_id
34
+ # end
35
+ # # Service returns: { id: "123" }
36
+ # # Output: { user_id: "123" }
37
+ # ```
38
+ #
39
+ # ## How It Works
40
+ #
41
+ # AsModifier doesn't transform values - it transforms attribute names.
42
+ # The orchestrator uses `target_name` to map source name to target name.
43
+ #
44
+ # ## Advanced Mode
45
+ #
46
+ # Schema format: `{ is: :symbol, message: nil }`
47
+ class AsModifier < Treaty::Entity::Attribute::Option::Base
48
+ # Validates that target name is a Symbol
49
+ #
50
+ # @raise [Treaty::Exceptions::Validation] If target is not a Symbol
51
+ # @return [void]
52
+ def validate_schema!
53
+ target = option_value
54
+
55
+ return if target.is_a?(Symbol)
56
+
57
+ raise Treaty::Exceptions::Validation,
58
+ I18n.t(
59
+ "treaty.attributes.modifiers.as.invalid_type",
60
+ attribute: @attribute_name,
61
+ type: target.class
62
+ )
63
+ end
64
+
65
+ # Indicates that AsModifier transforms attribute names
66
+ #
67
+ # @return [Boolean] Always returns true
68
+ def transforms_name?
69
+ true
70
+ end
71
+
72
+ # Returns the target name for the attribute
73
+ #
74
+ # @return [Symbol] The target attribute name
75
+ def target_name
76
+ option_value
77
+ end
78
+
79
+ # AsModifier doesn't modify the value itself, only the name
80
+ # The renaming is handled by the orchestrator using target_name
81
+ #
82
+ # @param value [Object] The value to transform
83
+ # @param _root_data [Hash] Unused root data parameter
84
+ # @return [Object] Unchanged value
85
+ def transform_value(value, _root_data = {})
86
+ value
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Entity
5
+ module Attribute
6
+ module Option
7
+ module Modifiers
8
+ # Converts attribute values between different types automatically.
9
+ #
10
+ # ## Usage Examples
11
+ #
12
+ # Simple mode:
13
+ # string :created_at, cast: :datetime
14
+ # datetime :timestamp, cast: :string
15
+ # integer :active, cast: :boolean
16
+ #
17
+ # Advanced mode with custom error message:
18
+ # string :created_at, cast: {
19
+ # to: :datetime,
20
+ # message: "Invalid date format"
21
+ # }
22
+ #
23
+ # ## Use Cases
24
+ #
25
+ # 1. **Request type conversion**:
26
+ # ```ruby
27
+ # request do
28
+ # string :created_at, cast: :datetime
29
+ # end
30
+ # # Input: { created_at: "2024-01-15T10:30:00Z" }
31
+ # # Service receives: { created_at: DateTime object }
32
+ # ```
33
+ #
34
+ # 2. **Response type conversion**:
35
+ # ```ruby
36
+ # response 200 do
37
+ # datetime :created_at, cast: :string
38
+ # end
39
+ # # Service returns: { created_at: DateTime object }
40
+ # # Output: { created_at: "2024-01-15T10:30:00Z" }
41
+ # ```
42
+ #
43
+ # 3. **Unix timestamp conversion**:
44
+ # ```ruby
45
+ # integer :timestamp, cast: :datetime
46
+ # datetime :created_at, cast: :integer
47
+ # ```
48
+ #
49
+ # ## Supported Conversions
50
+ #
51
+ # ### From Integer
52
+ # - integer -> string: Converts to string representation
53
+ # - integer -> boolean: 0 = false, non-zero = true
54
+ # - integer -> date: Treats as Unix timestamp, converts to date
55
+ # - integer -> time: Treats as Unix timestamp
56
+ # - integer -> datetime: Treats as Unix timestamp, converts to datetime
57
+ #
58
+ # ### From String
59
+ # - string -> integer: Parses integer from string
60
+ # - string -> boolean: Parses truthy/falsy strings (true/false, yes/no, 1/0, on/off)
61
+ # - string -> date: Parses date string
62
+ # - string -> time: Parses time string
63
+ # - string -> datetime: Parses datetime string (ISO8601, RFC3339, etc.)
64
+ #
65
+ # ### From Boolean
66
+ # - boolean -> string: Converts to "true" or "false"
67
+ # - boolean -> integer: true = 1, false = 0
68
+ #
69
+ # ### From Date
70
+ # - date -> string: Converts to ISO8601 format
71
+ # - date -> integer: Converts to Unix timestamp
72
+ # - date -> time: Converts to Time at midnight
73
+ # - date -> datetime: Converts to DateTime at midnight
74
+ #
75
+ # ### From Time
76
+ # - time -> string: Converts to ISO8601 format
77
+ # - time -> integer: Converts to Unix timestamp
78
+ # - time -> date: Converts to Date
79
+ # - time -> datetime: Converts to DateTime
80
+ #
81
+ # ### From DateTime
82
+ # - datetime -> string: Converts to ISO8601 format
83
+ # - datetime -> integer: Converts to Unix timestamp
84
+ # - datetime -> date: Converts to Date
85
+ # - datetime -> time: Converts to Time
86
+ #
87
+ # ## Important Notes
88
+ #
89
+ # - Cast option only works with scalar types (integer, string, boolean, date, time, datetime)
90
+ # - Array and Object types are not supported for casting
91
+ # - Casting to the same type is allowed (no-op)
92
+ # - Nil values are not transformed (handled by RequiredValidator)
93
+ # - All conversion errors are caught and re-raised as Validation errors
94
+ #
95
+ # ## Error Handling
96
+ #
97
+ # If conversion fails (e.g., invalid date string, non-numeric string to integer),
98
+ # the error is caught and converted to a Treaty::Exceptions::Validation error.
99
+ #
100
+ # ## Advanced Mode
101
+ #
102
+ # Schema format: `{ to: :target_type, message: "Custom error" }`
103
+ # Note: Uses `:to` key instead of the default `:is` key.
104
+ class CastModifier < Treaty::Entity::Attribute::Option::Base # rubocop:disable Metrics/ClassLength
105
+ # Types that support casting (scalar types only)
106
+ ALLOWED_CAST_TYPES = %i[integer string boolean date time datetime].freeze
107
+
108
+ # Validates that cast option is correctly configured
109
+ #
110
+ # @raise [Treaty::Exceptions::Validation] If cast configuration is invalid
111
+ # @return [void]
112
+ def validate_schema! # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
113
+ # If option_schema is nil, cast is not used for this attribute
114
+ return if @option_schema.nil?
115
+
116
+ target_type = option_value
117
+
118
+ # Validate that target type is a Symbol
119
+ unless target_type.is_a?(Symbol)
120
+ raise Treaty::Exceptions::Validation,
121
+ I18n.t(
122
+ "treaty.attributes.modifiers.cast.invalid_type",
123
+ attribute: @attribute_name,
124
+ type: target_type.class
125
+ )
126
+ end
127
+
128
+ # Validate that source type supports casting
129
+ unless ALLOWED_CAST_TYPES.include?(@attribute_type)
130
+ raise Treaty::Exceptions::Validation,
131
+ I18n.t(
132
+ "treaty.attributes.modifiers.cast.source_not_supported",
133
+ attribute: @attribute_name,
134
+ source_type: @attribute_type,
135
+ allowed: ALLOWED_CAST_TYPES.join(", ")
136
+ )
137
+ end
138
+
139
+ # Validate that target type is allowed
140
+ unless ALLOWED_CAST_TYPES.include?(target_type)
141
+ raise Treaty::Exceptions::Validation,
142
+ I18n.t(
143
+ "treaty.attributes.modifiers.cast.target_not_supported",
144
+ attribute: @attribute_name,
145
+ target_type:,
146
+ allowed: ALLOWED_CAST_TYPES.join(", ")
147
+ )
148
+ end
149
+
150
+ # Validate that conversion from source to target is supported
151
+ return if conversion_supported?(@attribute_type, target_type)
152
+
153
+ raise Treaty::Exceptions::Validation,
154
+ I18n.t(
155
+ "treaty.attributes.modifiers.cast.conversion_not_supported",
156
+ attribute: @attribute_name,
157
+ from: @attribute_type,
158
+ to: target_type
159
+ )
160
+ end
161
+
162
+ # Applies type conversion to the value
163
+ # Skips conversion for nil values (handled by RequiredValidator)
164
+ #
165
+ # @param value [Object] The current value
166
+ # @param _root_data [Hash] Unused root data parameter
167
+ # @return [Object] Converted value
168
+ def transform_value(value, _root_data = {}) # rubocop:disable Metrics/MethodLength
169
+ return value if value.nil? # Cast doesn't modify nil, required validator handles it.
170
+
171
+ target_type = option_value
172
+ conversion_lambda = conversion_matrix.dig(@attribute_type, target_type)
173
+
174
+ # Call conversion lambda
175
+ conversion_lambda.call(value:)
176
+ rescue StandardError => e
177
+ attributes = {
178
+ attribute: @attribute_name,
179
+ from: @attribute_type,
180
+ to: target_type,
181
+ value:,
182
+ error: e.message
183
+ }
184
+
185
+ # Catch all exceptions from conversion execution
186
+ error_message = resolve_custom_message(**attributes) || I18n.t(
187
+ "treaty.attributes.modifiers.cast.conversion_error",
188
+ **attributes
189
+ )
190
+
191
+ raise Treaty::Exceptions::Validation, error_message
192
+ end
193
+
194
+ protected
195
+
196
+ # Override value_key to use :to instead of :is
197
+ # This makes advanced mode syntax: cast: { to: :datetime }
198
+ #
199
+ # @return [Symbol] The key :to
200
+ def value_key
201
+ :to
202
+ end
203
+
204
+ private
205
+
206
+ # Checks if conversion from source type to target type is supported
207
+ #
208
+ # @param from_type [Symbol] Source type
209
+ # @param to_type [Symbol] Target type
210
+ # @return [Boolean] True if conversion is supported
211
+ def conversion_supported?(from_type, to_type)
212
+ conversion_matrix.dig(from_type, to_type).present?
213
+ end
214
+
215
+ # Matrix of all supported type conversions
216
+ # Maps from_type => to_type => conversion_lambda
217
+ #
218
+ # @return [Hash] Conversion matrix
219
+ def conversion_matrix # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
220
+ @conversion_matrix ||= {
221
+ integer: {
222
+ integer: ->(value:) { value }, # No-op for same type
223
+ string: ->(value:) { value.to_s },
224
+ boolean: ->(value:) { value != 0 },
225
+ date: ->(value:) { Time.at(value).to_date },
226
+ time: ->(value:) { Time.at(value) },
227
+ datetime: ->(value:) { Time.at(value).to_datetime }
228
+ },
229
+ string: {
230
+ string: ->(value:) { value }, # No-op for same type
231
+ integer: ->(value:) { Integer(value) },
232
+ boolean: ->(value:) { parse_boolean(value) },
233
+ date: ->(value:) { Date.parse(value) },
234
+ time: ->(value:) { Time.parse(value) },
235
+ datetime: ->(value:) { DateTime.parse(value) }
236
+ },
237
+ boolean: {
238
+ boolean: ->(value:) { value }, # No-op for same type
239
+ string: ->(value:) { value.to_s },
240
+ integer: ->(value:) { value ? 1 : 0 }
241
+ },
242
+ date: {
243
+ date: ->(value:) { value }, # No-op for same type
244
+ string: ->(value:) { value.iso8601 },
245
+ integer: ->(value:) { value.to_time.to_i },
246
+ time: ->(value:) { value.to_time },
247
+ datetime: ->(value:) { value.to_datetime }
248
+ },
249
+ time: {
250
+ time: ->(value:) { value }, # No-op for same type
251
+ string: ->(value:) { value.iso8601 },
252
+ integer: ->(value:) { value.to_i },
253
+ date: ->(value:) { value.to_date },
254
+ datetime: ->(value:) { value.to_datetime }
255
+ },
256
+ datetime: {
257
+ datetime: ->(value:) { value }, # No-op for same type
258
+ string: ->(value:) { value.iso8601 },
259
+ integer: ->(value:) { value.to_i },
260
+ date: ->(value:) { value.to_date },
261
+ time: ->(value:) { value.to_time }
262
+ }
263
+ }
264
+ end
265
+
266
+ # Parses a string value into a boolean
267
+ # Recognizes: true/false, yes/no, 1/0, on/off (case-insensitive)
268
+ #
269
+ # @param value [String] The string value to parse
270
+ # @return [Boolean] Parsed boolean value
271
+ # @raise [ArgumentError] If string is not a recognized boolean value
272
+ def parse_boolean(value)
273
+ normalized = value.to_s.downcase.strip
274
+
275
+ return true if %w[true 1 yes on].include?(normalized)
276
+ return false if %w[false 0 no off].include?(normalized)
277
+
278
+ raise ArgumentError, "Cannot convert '#{value}' to boolean"
279
+ end
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Entity
5
+ module Attribute
6
+ module Option
7
+ module Modifiers
8
+ # Computes attribute values from all available raw data.
9
+ #
10
+ # ## Key Difference from Transform
11
+ #
12
+ # - `transform:` receives only `value:` (the current attribute's value)
13
+ # - `computed:` receives `**attributes` (ALL raw data from root level)
14
+ #
15
+ # ## Usage Examples
16
+ #
17
+ # Simple mode:
18
+ # string :full_name, computed: (lambda do |**attributes|
19
+ # "#{attributes.dig(:user, :first_name)} #{attributes.dig(:user, :last_name)}"
20
+ # end)
21
+ #
22
+ # Advanced mode with custom error message:
23
+ # string :full_name, computed: {
24
+ # is: ->(**attributes) { "#{attributes.dig(:user, :first_name)} #{attributes.dig(:user, :last_name)}" },
25
+ # message: "Failed to compute full name"
26
+ # }
27
+ #
28
+ # ## Use Cases
29
+ #
30
+ # 1. **Derived fields (full name from parts)**:
31
+ # ```ruby
32
+ # response 200 do
33
+ # object :user do
34
+ # string :first_name
35
+ # string :last_name
36
+ # string :full_name, computed: (lambda do |**attributes|
37
+ # "#{attributes.dig(:user, :first_name)} #{attributes.dig(:user, :last_name)}"
38
+ # end)
39
+ # end
40
+ # end
41
+ # ```
42
+ #
43
+ # 2. **Calculated values (word count)**:
44
+ # ```ruby
45
+ # response 200 do
46
+ # object :post do
47
+ # string :content
48
+ # integer :word_count, computed: (lambda do |**attributes|
49
+ # attributes.dig(:post, :content).to_s.split.size
50
+ # end)
51
+ # end
52
+ # end
53
+ # ```
54
+ #
55
+ # 3. **Cross-object computations**:
56
+ # ```ruby
57
+ # response 200 do
58
+ # object :order do
59
+ # integer :quantity
60
+ # integer :unit_price
61
+ # integer :total, computed: (lambda do |**attributes|
62
+ # attributes.dig(:order, :quantity).to_i * attributes.dig(:order, :unit_price).to_i
63
+ # end)
64
+ # end
65
+ # end
66
+ # ```
67
+ #
68
+ # ## Important Notes
69
+ #
70
+ # - Lambda must accept `**attributes` (named argument splat)
71
+ # - Receives full raw data from root level (not just current object)
72
+ # - **Always computes** - ignores any existing value, result replaces everything
73
+ # - All exceptions raised in lambda are caught and re-raised as Validation errors
74
+ # - Computation is applied during Phase 3 (transformation phase)
75
+ # - Executes FIRST in modifier chain: computed -> transform -> cast -> default -> as
76
+ #
77
+ # ## Advanced Mode
78
+ #
79
+ # Schema format: `{ is: lambda, message: nil }`
80
+ class ComputedModifier < Treaty::Entity::Attribute::Option::Base
81
+ # Validates that computed value is a lambda
82
+ #
83
+ # @raise [Treaty::Exceptions::Validation] If computed is not a Proc/lambda
84
+ # @return [void]
85
+ def validate_schema!
86
+ computed_lambda = option_value
87
+
88
+ return if computed_lambda.respond_to?(:call)
89
+
90
+ raise Treaty::Exceptions::Validation,
91
+ I18n.t(
92
+ "treaty.attributes.modifiers.computed.invalid_type",
93
+ attribute: @attribute_name,
94
+ type: computed_lambda.class
95
+ )
96
+ end
97
+
98
+ # Computes value using the provided lambda and full root data
99
+ # Always executes - ignores any existing value
100
+ #
101
+ # @param _value [Object] The current value (ignored - always computes)
102
+ # @param root_data [Hash] Full raw data from root level
103
+ # @return [Object] Computed value
104
+ def transform_value(_value, root_data = {}) # rubocop:disable Metrics/MethodLength
105
+ computed_lambda = option_value
106
+
107
+ # Call lambda with full root data as named arguments
108
+ computed_lambda.call(**root_data)
109
+ rescue StandardError => e
110
+ attributes = {
111
+ attribute: @attribute_name,
112
+ error: e.message
113
+ }
114
+
115
+ # Catch all exceptions from lambda execution
116
+ error_message = resolve_custom_message(**attributes) || I18n.t(
117
+ "treaty.attributes.modifiers.computed.execution_error",
118
+ **attributes
119
+ )
120
+
121
+ raise Treaty::Exceptions::Validation, error_message
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end