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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/config/locales/en.yml +6 -2
- data/lib/treaty/engine.rb +1 -1
- data/lib/treaty/{attribute/entity → entity/attribute}/attribute.rb +4 -4
- data/lib/treaty/entity/attribute/base.rb +184 -0
- data/lib/treaty/entity/attribute/builder/base.rb +275 -0
- data/lib/treaty/entity/attribute/collection.rb +67 -0
- data/lib/treaty/entity/attribute/dsl.rb +92 -0
- data/lib/treaty/entity/attribute/helper_mapper.rb +74 -0
- data/lib/treaty/entity/attribute/option/base.rb +190 -0
- data/lib/treaty/entity/attribute/option/conditionals/base.rb +92 -0
- data/lib/treaty/entity/attribute/option/conditionals/if_conditional.rb +136 -0
- data/lib/treaty/entity/attribute/option/conditionals/unless_conditional.rb +153 -0
- data/lib/treaty/entity/attribute/option/modifiers/as_modifier.rb +93 -0
- data/lib/treaty/entity/attribute/option/modifiers/cast_modifier.rb +285 -0
- data/lib/treaty/entity/attribute/option/modifiers/computed_modifier.rb +128 -0
- data/lib/treaty/entity/attribute/option/modifiers/default_modifier.rb +105 -0
- data/lib/treaty/entity/attribute/option/modifiers/transform_modifier.rb +114 -0
- data/lib/treaty/entity/attribute/option/registry.rb +157 -0
- data/lib/treaty/entity/attribute/option/registry_initializer.rb +117 -0
- data/lib/treaty/entity/attribute/option/validators/format_validator.rb +222 -0
- data/lib/treaty/entity/attribute/option/validators/inclusion_validator.rb +94 -0
- data/lib/treaty/entity/attribute/option/validators/required_validator.rb +100 -0
- data/lib/treaty/entity/attribute/option/validators/type_validator.rb +219 -0
- data/lib/treaty/entity/attribute/option_normalizer.rb +168 -0
- data/lib/treaty/entity/attribute/option_orchestrator.rb +192 -0
- data/lib/treaty/entity/attribute/validation/attribute_validator.rb +147 -0
- data/lib/treaty/entity/attribute/validation/base.rb +76 -0
- data/lib/treaty/entity/attribute/validation/nested_array_validator.rb +207 -0
- data/lib/treaty/entity/attribute/validation/nested_object_validator.rb +105 -0
- data/lib/treaty/entity/attribute/validation/nested_transformer.rb +432 -0
- data/lib/treaty/entity/attribute/validation/orchestrator/base.rb +262 -0
- data/lib/treaty/entity/base.rb +90 -0
- data/lib/treaty/entity/builder.rb +44 -0
- data/lib/treaty/{info/entity → entity/info}/builder.rb +8 -8
- data/lib/treaty/{info/entity → entity/info}/dsl.rb +2 -2
- data/lib/treaty/{info/entity → entity/info}/result.rb +2 -2
- data/lib/treaty/entity.rb +7 -75
- data/lib/treaty/request/attribute/attribute.rb +1 -1
- data/lib/treaty/request/attribute/builder.rb +24 -1
- data/lib/treaty/request/entity.rb +1 -1
- data/lib/treaty/request/factory.rb +6 -6
- data/lib/treaty/request/validator.rb +1 -1
- data/lib/treaty/response/attribute/attribute.rb +1 -1
- data/lib/treaty/response/attribute/builder.rb +24 -1
- data/lib/treaty/response/entity.rb +1 -1
- data/lib/treaty/response/factory.rb +6 -6
- data/lib/treaty/response/validator.rb +1 -1
- data/lib/treaty/version.rb +1 -1
- metadata +35 -34
- data/lib/treaty/attribute/base.rb +0 -182
- data/lib/treaty/attribute/builder/base.rb +0 -143
- data/lib/treaty/attribute/collection.rb +0 -65
- data/lib/treaty/attribute/dsl.rb +0 -90
- data/lib/treaty/attribute/entity/builder.rb +0 -23
- data/lib/treaty/attribute/helper_mapper.rb +0 -72
- data/lib/treaty/attribute/option/base.rb +0 -188
- data/lib/treaty/attribute/option/conditionals/base.rb +0 -90
- data/lib/treaty/attribute/option/conditionals/if_conditional.rb +0 -134
- data/lib/treaty/attribute/option/conditionals/unless_conditional.rb +0 -151
- data/lib/treaty/attribute/option/modifiers/as_modifier.rb +0 -91
- data/lib/treaty/attribute/option/modifiers/cast_modifier.rb +0 -283
- data/lib/treaty/attribute/option/modifiers/computed_modifier.rb +0 -126
- data/lib/treaty/attribute/option/modifiers/default_modifier.rb +0 -103
- data/lib/treaty/attribute/option/modifiers/transform_modifier.rb +0 -112
- data/lib/treaty/attribute/option/registry.rb +0 -155
- data/lib/treaty/attribute/option/registry_initializer.rb +0 -115
- data/lib/treaty/attribute/option/validators/format_validator.rb +0 -220
- data/lib/treaty/attribute/option/validators/inclusion_validator.rb +0 -92
- data/lib/treaty/attribute/option/validators/required_validator.rb +0 -98
- data/lib/treaty/attribute/option/validators/type_validator.rb +0 -217
- data/lib/treaty/attribute/option_normalizer.rb +0 -166
- data/lib/treaty/attribute/option_orchestrator.rb +0 -190
- data/lib/treaty/attribute/validation/attribute_validator.rb +0 -145
- data/lib/treaty/attribute/validation/base.rb +0 -74
- data/lib/treaty/attribute/validation/nested_array_validator.rb +0 -205
- data/lib/treaty/attribute/validation/nested_object_validator.rb +0 -103
- data/lib/treaty/attribute/validation/nested_transformer.rb +0 -430
- 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
|