treaty 0.14.0 → 0.16.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 +17 -0
- data/lib/treaty/attribute/option/base.rb +14 -2
- data/lib/treaty/attribute/option/conditionals/base.rb +90 -0
- data/lib/treaty/attribute/option/conditionals/if_conditional.rb +134 -0
- data/lib/treaty/attribute/option/conditionals/unless_conditional.rb +151 -0
- data/lib/treaty/attribute/option/modifiers/as_modifier.rb +2 -1
- data/lib/treaty/attribute/option/modifiers/cast_modifier.rb +2 -1
- data/lib/treaty/attribute/option/modifiers/computed_modifier.rb +126 -0
- data/lib/treaty/attribute/option/modifiers/default_modifier.rb +2 -2
- data/lib/treaty/attribute/option/modifiers/transform_modifier.rb +2 -1
- data/lib/treaty/attribute/option/registry.rb +18 -2
- data/lib/treaty/attribute/option/registry_initializer.rb +24 -6
- data/lib/treaty/attribute/option_orchestrator.rb +3 -2
- data/lib/treaty/attribute/validation/attribute_validator.rb +3 -2
- data/lib/treaty/attribute/validation/nested_transformer.rb +202 -25
- data/lib/treaty/attribute/validation/orchestrator/base.rb +85 -3
- data/lib/treaty/result.rb +4 -3
- data/lib/treaty/version.rb +1 -1
- data/lib/treaty/versions/workspace.rb +2 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc42fa86dd5dc35d49f0a07a189d9ddc59f619e2341905c1ed62193e0d3069b7
|
|
4
|
+
data.tar.gz: ffc66f03e0ced6e666bb12db50f405117831b48c26475ab034ab4a16f4c431b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0b31ad204ddb19df8a296007f334d29e2e220fbb771f9c5a3851309e0a9ec1909dae0dbef3608633f6a5203da0b827fb90c75132e83f5ebb3fcc28f36fd33cc9
|
|
7
|
+
data.tar.gz: 0c6ec71d1be9155da662498264667a63072c5ce513d1ae0987a54091d76aa35a9fd21b3c722db38a8f4154919a2894f442736c44e23ff88961c9a1aadc841c49
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
15
|
> [!WARNING]
|
|
16
|
-
> **Development Status**: Treaty is currently under active development in the 0.x version series. Breaking changes may occur between minor versions (0.x) as we refine the API and add new features. The library will stabilize with the 1.0 release. We recommend pinning to specific patch versions in your Gemfile (e.g., `gem "treaty", "~> 0.
|
|
16
|
+
> **Development Status**: Treaty is currently under active development in the 0.x version series. Breaking changes may occur between minor versions (0.x) as we refine the API and add new features. The library will stabilize with the 1.0 release. We recommend pinning to specific patch versions in your Gemfile (e.g., `gem "treaty", "~> 0.16.0"`) until the 1.0 release.
|
|
17
17
|
|
|
18
18
|
## 📚 Documentation
|
|
19
19
|
|
|
@@ -133,4 +133,4 @@ Thank you to all [contributors](https://github.com/servactory/treaty/graphs/cont
|
|
|
133
133
|
|
|
134
134
|
## 📄 License
|
|
135
135
|
|
|
136
|
-
Treaty is available as open source under the terms of the [MIT License](
|
|
136
|
+
Treaty is available as open source under the terms of the [MIT License](./LICENSE).
|
data/config/locales/en.yml
CHANGED
|
@@ -45,6 +45,7 @@ en:
|
|
|
45
45
|
# Attribute options
|
|
46
46
|
options:
|
|
47
47
|
unknown: "Unknown options for attribute '%{attribute}': %{unknown}. Known options: %{known}"
|
|
48
|
+
message_evaluation_error: "Custom message evaluation failed for attribute '%{attribute}': %{error}"
|
|
48
49
|
|
|
49
50
|
# Attribute modifiers
|
|
50
51
|
modifiers:
|
|
@@ -55,6 +56,10 @@ en:
|
|
|
55
56
|
invalid_type: "Option 'transform' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
|
|
56
57
|
execution_error: "Transform failed for attribute '%{attribute}': %{error}"
|
|
57
58
|
|
|
59
|
+
computed:
|
|
60
|
+
invalid_type: "Option 'computed' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
|
|
61
|
+
execution_error: "Computed failed for attribute '%{attribute}': %{error}"
|
|
62
|
+
|
|
58
63
|
cast:
|
|
59
64
|
invalid_type: "Option 'cast' for attribute '%{attribute}' must be a Symbol. Got: %{type}"
|
|
60
65
|
source_not_supported: "Option 'cast' for attribute '%{attribute}' cannot be used with type '%{source_type}'. Casting is only supported for: %{allowed}"
|
|
@@ -62,6 +67,18 @@ en:
|
|
|
62
67
|
conversion_not_supported: "Option 'cast' for attribute '%{attribute}' does not support conversion from '%{from}' to '%{to}'"
|
|
63
68
|
conversion_error: "Cast failed for attribute '%{attribute}' from '%{from}' to '%{to}'. Value: '%{value}'. Error: %{error}"
|
|
64
69
|
|
|
70
|
+
# Attribute conditionals
|
|
71
|
+
conditionals:
|
|
72
|
+
if:
|
|
73
|
+
invalid_type: "Option 'if' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
|
|
74
|
+
evaluation_error: "Conditional evaluation failed for attribute '%{attribute}': %{error}"
|
|
75
|
+
|
|
76
|
+
unless:
|
|
77
|
+
invalid_type: "Option 'unless' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
|
|
78
|
+
evaluation_error: "Conditional evaluation failed for attribute '%{attribute}': %{error}"
|
|
79
|
+
|
|
80
|
+
mutual_exclusivity_error: "Attribute '%{attribute}' cannot have both 'if' and 'unless' options. Use only one conditional option."
|
|
81
|
+
|
|
65
82
|
# Attribute builder DSL
|
|
66
83
|
builder:
|
|
67
84
|
not_implemented: "%{class} must implement #create_attribute"
|
|
@@ -76,8 +76,9 @@ module Treaty
|
|
|
76
76
|
# Override in subclasses if transformation is needed
|
|
77
77
|
#
|
|
78
78
|
# @param value [Object] The value to transform
|
|
79
|
+
# @param _root_data [Hash] Full raw data from root level (used by computed modifier)
|
|
79
80
|
# @return [Object] Transformed value
|
|
80
|
-
def transform_value(value)
|
|
81
|
+
def transform_value(value, _root_data = {})
|
|
81
82
|
value
|
|
82
83
|
end
|
|
83
84
|
|
|
@@ -143,10 +144,12 @@ module Treaty
|
|
|
143
144
|
|
|
144
145
|
# Resolves custom message with lambda support
|
|
145
146
|
# If message is a lambda, calls it with provided named arguments
|
|
147
|
+
# Catches all exceptions from lambda execution and re-raises as Validation errors
|
|
146
148
|
#
|
|
147
149
|
# @param attributes [Hash] Named arguments to pass to lambda
|
|
148
150
|
# @return [String, nil] Resolved message string or nil
|
|
149
|
-
|
|
151
|
+
# @raise [Treaty::Exceptions::Validation] If custom message lambda raises an exception
|
|
152
|
+
def resolve_custom_message(**attributes) # rubocop:disable Metrics/MethodLength
|
|
150
153
|
message = custom_message
|
|
151
154
|
return nil if message.nil?
|
|
152
155
|
|
|
@@ -155,6 +158,15 @@ module Treaty
|
|
|
155
158
|
else
|
|
156
159
|
message
|
|
157
160
|
end
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
# Catch all exceptions from custom message lambda execution
|
|
163
|
+
error_message = I18n.t(
|
|
164
|
+
"treaty.attributes.options.message_evaluation_error",
|
|
165
|
+
attribute: @attribute_name,
|
|
166
|
+
error: e.message
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
raise Treaty::Exceptions::Validation, error_message
|
|
158
170
|
end
|
|
159
171
|
|
|
160
172
|
# Checks if schema is in advanced mode
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Treaty
|
|
4
|
+
module Attribute
|
|
5
|
+
module Option
|
|
6
|
+
module Conditionals
|
|
7
|
+
# Base class for conditional option processors.
|
|
8
|
+
#
|
|
9
|
+
# ## Purpose
|
|
10
|
+
#
|
|
11
|
+
# Conditionals control whether an attribute should be processed at all.
|
|
12
|
+
# Unlike validators (which check data) and modifiers (which transform data),
|
|
13
|
+
# conditionals determine attribute visibility based on runtime conditions.
|
|
14
|
+
#
|
|
15
|
+
# ## Key Difference from Validators/Modifiers
|
|
16
|
+
#
|
|
17
|
+
# - **Validators**: Check if data is valid
|
|
18
|
+
# - **Modifiers**: Transform data values
|
|
19
|
+
# - **Conditionals**: Decide if attribute exists in output
|
|
20
|
+
#
|
|
21
|
+
# ## Processing
|
|
22
|
+
#
|
|
23
|
+
# Conditionals are evaluated BEFORE validators and modifiers:
|
|
24
|
+
# 1. If condition evaluates to `false` → attribute is skipped entirely
|
|
25
|
+
# 2. If condition evaluates to `true` → attribute is processed normally
|
|
26
|
+
#
|
|
27
|
+
# ## Mode Support
|
|
28
|
+
#
|
|
29
|
+
# Conditionals do NOT support simple/advanced modes.
|
|
30
|
+
# They only accept lambda/proc directly:
|
|
31
|
+
#
|
|
32
|
+
# ```ruby
|
|
33
|
+
# # Correct
|
|
34
|
+
# integer :rating, if: ->(**attributes) { attributes.dig(:post, :published_at).present? }
|
|
35
|
+
# array :tags, if: ->(post:) { post[:published_at].present? }
|
|
36
|
+
#
|
|
37
|
+
# # Incorrect - no simple/advanced mode
|
|
38
|
+
# integer :rating, if: true # Not supported
|
|
39
|
+
# integer :rating, if: { is: ..., message: ... } # Not supported
|
|
40
|
+
# ```
|
|
41
|
+
#
|
|
42
|
+
# ## Implementation
|
|
43
|
+
#
|
|
44
|
+
# Subclasses must implement:
|
|
45
|
+
# - `validate_schema!` - Validate the conditional schema at definition time
|
|
46
|
+
# - `evaluate_condition(data)` - Evaluate condition with runtime data
|
|
47
|
+
class Base < Treaty::Attribute::Option::Base
|
|
48
|
+
# Phase 1: Validates conditional schema
|
|
49
|
+
# Must be overridden in subclasses
|
|
50
|
+
#
|
|
51
|
+
# @raise [Treaty::Exceptions::Validation] If schema is invalid
|
|
52
|
+
# @return [void]
|
|
53
|
+
def validate_schema!
|
|
54
|
+
raise Treaty::Exceptions::NotImplemented,
|
|
55
|
+
"#{self.class} must implement #validate_schema!"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Evaluates the conditional with runtime data
|
|
59
|
+
# Must be overridden in subclasses
|
|
60
|
+
#
|
|
61
|
+
# @param _data [Hash] Raw data to evaluate condition against
|
|
62
|
+
# @raise [Treaty::Exceptions::Validation] If evaluation fails
|
|
63
|
+
# @return [Boolean] True if attribute should be processed, false otherwise
|
|
64
|
+
def evaluate_condition(_data)
|
|
65
|
+
raise Treaty::Exceptions::NotImplemented,
|
|
66
|
+
"#{self.class} must implement #evaluate_condition"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Conditionals do not validate values
|
|
70
|
+
# This is a no-op for conditionals
|
|
71
|
+
#
|
|
72
|
+
# @param _value [Object] The value (unused)
|
|
73
|
+
# @return [void]
|
|
74
|
+
def validate_value!(_value)
|
|
75
|
+
# No-op: conditionals don't validate values
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Conditionals do not transform values
|
|
79
|
+
# This is a no-op for conditionals
|
|
80
|
+
#
|
|
81
|
+
# @param value [Object] The value to pass through
|
|
82
|
+
# @return [Object] The unchanged value
|
|
83
|
+
def transform_value(value)
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Treaty
|
|
4
|
+
module Attribute
|
|
5
|
+
module Option
|
|
6
|
+
module Conditionals
|
|
7
|
+
# Conditionally includes attributes based on runtime data evaluation.
|
|
8
|
+
#
|
|
9
|
+
# ## Usage Examples
|
|
10
|
+
#
|
|
11
|
+
# Basic usage with keyword arguments splat:
|
|
12
|
+
# array :tags, if: ->(**attributes) { attributes.dig(:post, :published_at).present? }
|
|
13
|
+
# integer :rating, if: ->(**attributes) { attributes.dig(:post, :published_at).present? }
|
|
14
|
+
#
|
|
15
|
+
# Named argument pattern:
|
|
16
|
+
# array :tags, if: ->(post:) { post[:published_at].present? }
|
|
17
|
+
# integer :views, if: ->(post:) { post[:published_at].present? }
|
|
18
|
+
#
|
|
19
|
+
# Complex conditions:
|
|
20
|
+
# string :admin_note, if: ->(**attrs) {
|
|
21
|
+
# attrs.dig(:user, :role) == "admin" && attrs.dig(:post, :flagged)
|
|
22
|
+
# }
|
|
23
|
+
#
|
|
24
|
+
# ## Use Cases
|
|
25
|
+
#
|
|
26
|
+
# 1. **Show fields only when published**:
|
|
27
|
+
# ```ruby
|
|
28
|
+
# response 200 do
|
|
29
|
+
# object :post do
|
|
30
|
+
# string :id
|
|
31
|
+
# string :title
|
|
32
|
+
# datetime :published_at, :optional
|
|
33
|
+
# integer :rating, if: ->(**attrs) { attrs.dig(:post, :published_at).present? }
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
# # If published_at is nil → rating is excluded from response
|
|
37
|
+
# # If published_at exists → rating is included
|
|
38
|
+
# ```
|
|
39
|
+
#
|
|
40
|
+
# 2. **Role-based field visibility**:
|
|
41
|
+
# ```ruby
|
|
42
|
+
# response 200 do
|
|
43
|
+
# object :user do
|
|
44
|
+
# string :name
|
|
45
|
+
# string :email, if: ->(user:) { user[:role] == "admin" }
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
# ```
|
|
49
|
+
#
|
|
50
|
+
# 3. **Nested attribute conditionals**:
|
|
51
|
+
# ```ruby
|
|
52
|
+
# object :post do
|
|
53
|
+
# string :title
|
|
54
|
+
# array :tags, if: ->(post:) { post[:published_at].present? } do
|
|
55
|
+
# string :_self
|
|
56
|
+
# end
|
|
57
|
+
# end
|
|
58
|
+
# ```
|
|
59
|
+
#
|
|
60
|
+
# ## Important Notes
|
|
61
|
+
#
|
|
62
|
+
# - Lambda receives raw data as named arguments
|
|
63
|
+
# - Lambda MUST return truthy/falsy value
|
|
64
|
+
# - If condition is false → attribute is completely omitted
|
|
65
|
+
# - If condition is true → attribute is validated and transformed normally
|
|
66
|
+
# - All exceptions in lambda are caught and wrapped in Treaty::Exceptions::Validation
|
|
67
|
+
# - Does NOT support simple mode (if: true) or advanced mode (if: { is: ..., message: ... })
|
|
68
|
+
#
|
|
69
|
+
# ## Error Handling
|
|
70
|
+
#
|
|
71
|
+
# If the lambda raises any exception, it's caught and converted to a
|
|
72
|
+
# Treaty::Exceptions::Validation with detailed error message including:
|
|
73
|
+
# - Attribute name
|
|
74
|
+
# - Original exception message
|
|
75
|
+
#
|
|
76
|
+
# ## Data Access Pattern
|
|
77
|
+
#
|
|
78
|
+
# The lambda receives the same data structure that the orchestrator processes.
|
|
79
|
+
# For nested attributes, you can access parent data using dig:
|
|
80
|
+
#
|
|
81
|
+
# ```ruby
|
|
82
|
+
# # For response with { post: { title: "...", published_at: "..." } }
|
|
83
|
+
# integer :rating, if: ->(**attrs) { attrs.dig(:post, :published_at).present? }
|
|
84
|
+
#
|
|
85
|
+
# # Alternative: named argument pattern
|
|
86
|
+
# integer :rating, if: ->(post:) { post[:published_at].present? }
|
|
87
|
+
# ```
|
|
88
|
+
class IfConditional < Treaty::Attribute::Option::Conditionals::Base
|
|
89
|
+
# Validates that if option is a callable (Proc/Lambda)
|
|
90
|
+
#
|
|
91
|
+
# @raise [Treaty::Exceptions::Validation] If if is not a Proc/lambda
|
|
92
|
+
# @return [void]
|
|
93
|
+
def validate_schema!
|
|
94
|
+
conditional_lambda = @option_schema
|
|
95
|
+
|
|
96
|
+
return if conditional_lambda.respond_to?(:call)
|
|
97
|
+
|
|
98
|
+
raise Treaty::Exceptions::Validation,
|
|
99
|
+
I18n.t(
|
|
100
|
+
"treaty.attributes.conditionals.if.invalid_type",
|
|
101
|
+
attribute: @attribute_name,
|
|
102
|
+
type: conditional_lambda.class
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Evaluates the conditional lambda with runtime data
|
|
107
|
+
# Returns boolean indicating if attribute should be processed
|
|
108
|
+
#
|
|
109
|
+
# @param data [Hash] Raw data from request/response/entity
|
|
110
|
+
# @raise [Treaty::Exceptions::Validation] If lambda execution fails
|
|
111
|
+
# @return [Boolean] True if attribute should be processed, false to skip it
|
|
112
|
+
def evaluate_condition(data)
|
|
113
|
+
conditional_lambda = @option_schema
|
|
114
|
+
|
|
115
|
+
# Call lambda with raw data as named arguments
|
|
116
|
+
# The lambda can use **attributes or specific named args like post:
|
|
117
|
+
result = conditional_lambda.call(**data)
|
|
118
|
+
|
|
119
|
+
# Convert result to boolean
|
|
120
|
+
!!result
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
# Catch all exceptions from lambda execution
|
|
123
|
+
raise Treaty::Exceptions::Validation,
|
|
124
|
+
I18n.t(
|
|
125
|
+
"treaty.attributes.conditionals.if.evaluation_error",
|
|
126
|
+
attribute: @attribute_name,
|
|
127
|
+
error: e.message
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Treaty
|
|
4
|
+
module Attribute
|
|
5
|
+
module Option
|
|
6
|
+
module Conditionals
|
|
7
|
+
# Conditionally excludes attributes based on runtime data evaluation.
|
|
8
|
+
#
|
|
9
|
+
# ## Usage Examples
|
|
10
|
+
#
|
|
11
|
+
# Basic usage with keyword arguments splat:
|
|
12
|
+
# array :tags, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
|
|
13
|
+
# integer :draft_views, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
|
|
14
|
+
#
|
|
15
|
+
# Named argument pattern:
|
|
16
|
+
# array :draft_notes, unless: ->(post:) { post[:published_at].present? }
|
|
17
|
+
# integer :edit_count, unless: ->(post:) { post[:published_at].present? }
|
|
18
|
+
#
|
|
19
|
+
# Complex conditions:
|
|
20
|
+
# string :internal_note, unless: ->(**attrs) {
|
|
21
|
+
# attrs.dig(:user, :role) == "admin" && attrs.dig(:post, :flagged)
|
|
22
|
+
# }
|
|
23
|
+
#
|
|
24
|
+
# ## Use Cases
|
|
25
|
+
#
|
|
26
|
+
# 1. **Hide fields when published**:
|
|
27
|
+
# ```ruby
|
|
28
|
+
# response 200 do
|
|
29
|
+
# object :post do
|
|
30
|
+
# string :id
|
|
31
|
+
# string :title
|
|
32
|
+
# datetime :published_at, :optional
|
|
33
|
+
# integer :draft_views, unless: ->(**attrs) { attrs.dig(:post, :published_at).present? }
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
# # If published_at is nil → draft_views is included in response
|
|
37
|
+
# # If published_at exists → draft_views is excluded
|
|
38
|
+
# ```
|
|
39
|
+
#
|
|
40
|
+
# 2. **Role-based field exclusion**:
|
|
41
|
+
# ```ruby
|
|
42
|
+
# response 200 do
|
|
43
|
+
# object :user do
|
|
44
|
+
# string :name
|
|
45
|
+
# string :internal_id, unless: ->(user:) { user[:role] == "public" }
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
# ```
|
|
49
|
+
#
|
|
50
|
+
# 3. **Nested attribute conditionals**:
|
|
51
|
+
# ```ruby
|
|
52
|
+
# object :post do
|
|
53
|
+
# string :title
|
|
54
|
+
# array :draft_notes, unless: ->(post:) { post[:published_at].present? } do
|
|
55
|
+
# string :_self
|
|
56
|
+
# end
|
|
57
|
+
# end
|
|
58
|
+
# ```
|
|
59
|
+
#
|
|
60
|
+
# ## Important Notes
|
|
61
|
+
#
|
|
62
|
+
# - Lambda receives raw data as named arguments
|
|
63
|
+
# - Lambda MUST return truthy/falsy value
|
|
64
|
+
# - If condition is true → attribute is completely omitted (OPPOSITE of `if`)
|
|
65
|
+
# - If condition is false → attribute is validated and transformed normally
|
|
66
|
+
# - All exceptions in lambda are caught and wrapped in Treaty::Exceptions::Validation
|
|
67
|
+
# - Does NOT support simple mode (unless: true) or advanced mode (unless: { is: ..., message: ... })
|
|
68
|
+
#
|
|
69
|
+
# ## Difference from `if` Option
|
|
70
|
+
#
|
|
71
|
+
# `unless` is the logical opposite of `if`:
|
|
72
|
+
# - `if` includes attribute when condition is TRUE
|
|
73
|
+
# - `unless` includes attribute when condition is FALSE
|
|
74
|
+
#
|
|
75
|
+
# ```ruby
|
|
76
|
+
# # These are equivalent:
|
|
77
|
+
# integer :rating, if: ->(**attrs) { attrs.dig(:post, :published_at).present? }
|
|
78
|
+
# integer :rating, unless: ->(**attrs) { attrs.dig(:post, :published_at).blank? }
|
|
79
|
+
#
|
|
80
|
+
# # These are also equivalent:
|
|
81
|
+
# integer :draft_views, unless: ->(**attrs) { attrs.dig(:post, :published_at).present? }
|
|
82
|
+
# integer :draft_views, if: ->(**attrs) { attrs.dig(:post, :published_at).blank? }
|
|
83
|
+
# ```
|
|
84
|
+
#
|
|
85
|
+
# ## Error Handling
|
|
86
|
+
#
|
|
87
|
+
# If the lambda raises any exception, it's caught and converted to a
|
|
88
|
+
# Treaty::Exceptions::Validation with detailed error message including:
|
|
89
|
+
# - Attribute name
|
|
90
|
+
# - Original exception message
|
|
91
|
+
#
|
|
92
|
+
# ## Data Access Pattern
|
|
93
|
+
#
|
|
94
|
+
# The lambda receives the same data structure that the orchestrator processes.
|
|
95
|
+
# For nested attributes, you can access parent data using dig:
|
|
96
|
+
#
|
|
97
|
+
# ```ruby
|
|
98
|
+
# # For response with { post: { title: "...", published_at: "..." } }
|
|
99
|
+
# integer :draft_views, unless: ->(**attrs) { attrs.dig(:post, :published_at).present? }
|
|
100
|
+
#
|
|
101
|
+
# # Alternative: named argument pattern
|
|
102
|
+
# integer :draft_views, unless: ->(post:) { post[:published_at].present? }
|
|
103
|
+
# ```
|
|
104
|
+
class UnlessConditional < Treaty::Attribute::Option::Conditionals::Base
|
|
105
|
+
# Validates that unless option is a callable (Proc/Lambda)
|
|
106
|
+
#
|
|
107
|
+
# @raise [Treaty::Exceptions::Validation] If unless is not a Proc/lambda
|
|
108
|
+
# @return [void]
|
|
109
|
+
def validate_schema!
|
|
110
|
+
conditional_lambda = @option_schema
|
|
111
|
+
|
|
112
|
+
return if conditional_lambda.respond_to?(:call)
|
|
113
|
+
|
|
114
|
+
raise Treaty::Exceptions::Validation,
|
|
115
|
+
I18n.t(
|
|
116
|
+
"treaty.attributes.conditionals.unless.invalid_type",
|
|
117
|
+
attribute: @attribute_name,
|
|
118
|
+
type: conditional_lambda.class
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Evaluates the conditional lambda with runtime data
|
|
123
|
+
# Returns boolean indicating if attribute should be processed
|
|
124
|
+
#
|
|
125
|
+
# @param data [Hash] Raw data from request/response/entity
|
|
126
|
+
# @raise [Treaty::Exceptions::Validation] If lambda execution fails
|
|
127
|
+
# @return [Boolean] True if attribute should be processed (when condition is FALSE), false to skip it
|
|
128
|
+
def evaluate_condition(data)
|
|
129
|
+
conditional_lambda = @option_schema
|
|
130
|
+
|
|
131
|
+
# Call lambda with raw data as named arguments
|
|
132
|
+
# The lambda can use **attributes or specific named args like post:
|
|
133
|
+
result = conditional_lambda.call(**data)
|
|
134
|
+
|
|
135
|
+
# Convert result to boolean and NEGATE it (opposite of if)
|
|
136
|
+
# unless includes attribute when condition is FALSE
|
|
137
|
+
!result
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
# Catch all exceptions from lambda execution
|
|
140
|
+
raise Treaty::Exceptions::Validation,
|
|
141
|
+
I18n.t(
|
|
142
|
+
"treaty.attributes.conditionals.unless.evaluation_error",
|
|
143
|
+
attribute: @attribute_name,
|
|
144
|
+
error: e.message
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -79,8 +79,9 @@ module Treaty
|
|
|
79
79
|
# The renaming is handled by the orchestrator using target_name
|
|
80
80
|
#
|
|
81
81
|
# @param value [Object] The value to transform
|
|
82
|
+
# @param _root_data [Hash] Unused root data parameter
|
|
82
83
|
# @return [Object] Unchanged value
|
|
83
|
-
def transform_value(value)
|
|
84
|
+
def transform_value(value, _root_data = {})
|
|
84
85
|
value
|
|
85
86
|
end
|
|
86
87
|
end
|
|
@@ -162,8 +162,9 @@ module Treaty
|
|
|
162
162
|
# Skips conversion for nil values (handled by RequiredValidator)
|
|
163
163
|
#
|
|
164
164
|
# @param value [Object] The current value
|
|
165
|
+
# @param _root_data [Hash] Unused root data parameter
|
|
165
166
|
# @return [Object] Converted value
|
|
166
|
-
def transform_value(value) # rubocop:disable Metrics/MethodLength
|
|
167
|
+
def transform_value(value, _root_data = {}) # rubocop:disable Metrics/MethodLength
|
|
167
168
|
return value if value.nil? # Cast doesn't modify nil, required validator handles it.
|
|
168
169
|
|
|
169
170
|
target_type = option_value
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Treaty
|
|
4
|
+
module Attribute
|
|
5
|
+
module Option
|
|
6
|
+
module Modifiers
|
|
7
|
+
# Computes attribute values from all available raw data.
|
|
8
|
+
#
|
|
9
|
+
# ## Key Difference from Transform
|
|
10
|
+
#
|
|
11
|
+
# - `transform:` receives only `value:` (the current attribute's value)
|
|
12
|
+
# - `computed:` receives `**attributes` (ALL raw data from root level)
|
|
13
|
+
#
|
|
14
|
+
# ## Usage Examples
|
|
15
|
+
#
|
|
16
|
+
# Simple mode:
|
|
17
|
+
# string :full_name, computed: ->(**attrs) {
|
|
18
|
+
# "#{attrs.dig(:user, :first_name)} #{attrs.dig(:user, :last_name)}"
|
|
19
|
+
# }
|
|
20
|
+
#
|
|
21
|
+
# Advanced mode with custom error message:
|
|
22
|
+
# string :full_name, computed: {
|
|
23
|
+
# is: ->(**attrs) { "#{attrs.dig(:user, :first_name)} #{attrs.dig(:user, :last_name)}" },
|
|
24
|
+
# message: "Failed to compute full name"
|
|
25
|
+
# }
|
|
26
|
+
#
|
|
27
|
+
# ## Use Cases
|
|
28
|
+
#
|
|
29
|
+
# 1. **Derived fields (full name from parts)**:
|
|
30
|
+
# ```ruby
|
|
31
|
+
# response 200 do
|
|
32
|
+
# object :user do
|
|
33
|
+
# string :first_name
|
|
34
|
+
# string :last_name
|
|
35
|
+
# string :full_name, computed: ->(**attrs) {
|
|
36
|
+
# "#{attrs.dig(:user, :first_name)} #{attrs.dig(:user, :last_name)}"
|
|
37
|
+
# }
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
# ```
|
|
41
|
+
#
|
|
42
|
+
# 2. **Calculated values (word count)**:
|
|
43
|
+
# ```ruby
|
|
44
|
+
# response 200 do
|
|
45
|
+
# object :post do
|
|
46
|
+
# string :content
|
|
47
|
+
# integer :word_count, computed: ->(**attrs) {
|
|
48
|
+
# attrs.dig(:post, :content).to_s.split.size
|
|
49
|
+
# }
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
# ```
|
|
53
|
+
#
|
|
54
|
+
# 3. **Cross-object computations**:
|
|
55
|
+
# ```ruby
|
|
56
|
+
# response 200 do
|
|
57
|
+
# object :order do
|
|
58
|
+
# integer :quantity
|
|
59
|
+
# integer :unit_price
|
|
60
|
+
# integer :total, computed: ->(**attrs) {
|
|
61
|
+
# attrs.dig(:order, :quantity).to_i * attrs.dig(:order, :unit_price).to_i
|
|
62
|
+
# }
|
|
63
|
+
# end
|
|
64
|
+
# end
|
|
65
|
+
# ```
|
|
66
|
+
#
|
|
67
|
+
# ## Important Notes
|
|
68
|
+
#
|
|
69
|
+
# - Lambda must accept `**attributes` (named argument splat)
|
|
70
|
+
# - Receives full raw data from root level (not just current object)
|
|
71
|
+
# - **Always computes** - ignores any existing value, result replaces everything
|
|
72
|
+
# - All exceptions raised in lambda are caught and re-raised as Validation errors
|
|
73
|
+
# - Computation is applied during Phase 3 (transformation phase)
|
|
74
|
+
# - Executes FIRST in modifier chain: computed -> transform -> cast -> default -> as
|
|
75
|
+
#
|
|
76
|
+
# ## Advanced Mode
|
|
77
|
+
#
|
|
78
|
+
# Schema format: `{ is: lambda, message: nil }`
|
|
79
|
+
class ComputedModifier < Treaty::Attribute::Option::Base
|
|
80
|
+
# Validates that computed value is a lambda
|
|
81
|
+
#
|
|
82
|
+
# @raise [Treaty::Exceptions::Validation] If computed is not a Proc/lambda
|
|
83
|
+
# @return [void]
|
|
84
|
+
def validate_schema!
|
|
85
|
+
computed_lambda = option_value
|
|
86
|
+
|
|
87
|
+
return if computed_lambda.respond_to?(:call)
|
|
88
|
+
|
|
89
|
+
raise Treaty::Exceptions::Validation,
|
|
90
|
+
I18n.t(
|
|
91
|
+
"treaty.attributes.modifiers.computed.invalid_type",
|
|
92
|
+
attribute: @attribute_name,
|
|
93
|
+
type: computed_lambda.class
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Computes value using the provided lambda and full root data
|
|
98
|
+
# Always executes - ignores any existing value
|
|
99
|
+
#
|
|
100
|
+
# @param _value [Object] The current value (ignored - always computes)
|
|
101
|
+
# @param root_data [Hash] Full raw data from root level
|
|
102
|
+
# @return [Object] Computed value
|
|
103
|
+
def transform_value(_value, root_data = {}) # rubocop:disable Metrics/MethodLength
|
|
104
|
+
computed_lambda = option_value
|
|
105
|
+
|
|
106
|
+
# Call lambda with full root data as named arguments
|
|
107
|
+
computed_lambda.call(**root_data)
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
attributes = {
|
|
110
|
+
attribute: @attribute_name,
|
|
111
|
+
error: e.message
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Catch all exceptions from lambda execution
|
|
115
|
+
error_message = resolve_custom_message(**attributes) || I18n.t(
|
|
116
|
+
"treaty.attributes.modifiers.computed.execution_error",
|
|
117
|
+
**attributes
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
raise Treaty::Exceptions::Validation, error_message
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -80,9 +80,9 @@ module Treaty
|
|
|
80
80
|
# Empty strings, empty arrays, and false are NOT replaced
|
|
81
81
|
#
|
|
82
82
|
# @param value [Object] The current value
|
|
83
|
-
# @param
|
|
83
|
+
# @param _root_data [Hash] Unused root data parameter
|
|
84
84
|
# @return [Object] Default value if original is nil, otherwise original value
|
|
85
|
-
def transform_value(value,
|
|
85
|
+
def transform_value(value, _root_data = {})
|
|
86
86
|
# Only apply default if value is nil
|
|
87
87
|
# Empty strings, empty arrays, false are NOT replaced
|
|
88
88
|
return value unless value.nil?
|
|
@@ -82,8 +82,9 @@ module Treaty
|
|
|
82
82
|
# Skips transformation for nil values (handled by RequiredValidator)
|
|
83
83
|
#
|
|
84
84
|
# @param value [Object] The current value
|
|
85
|
+
# @param _root_data [Hash] Unused root data parameter
|
|
85
86
|
# @return [Object] Transformed value
|
|
86
|
-
def transform_value(value) # rubocop:disable Metrics/MethodLength
|
|
87
|
+
def transform_value(value, _root_data = {}) # rubocop:disable Metrics/MethodLength
|
|
87
88
|
return value if value.nil? # Transform doesn't modify nil, required validator handles it.
|
|
88
89
|
|
|
89
90
|
transform_lambda = option_value
|