treaty 0.14.0 → 0.15.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: 1fe35b7cf209d578b34ee7d904bdd146db4a3ac61f022a1f5f02c80bfe21875e
4
- data.tar.gz: 4be4a2f29fbfb30115e696baeaf618eaf59fe5c2fc309a0d1ced3eff61713671
3
+ metadata.gz: a3fcf5acc02ad345e7ff61ba70124596a89be46af035b6ee5d4114de8509f883
4
+ data.tar.gz: bc5d32074c9cab57438851555fb6849014b52da7f0732517a54db2f48563a198
5
5
  SHA512:
6
- metadata.gz: d256fd30a7553ebfc015c06049c4fc0edceddaa782b3b0bda807364568009465556e72867324bc4a707d61c52da579f5a40f4324922af4349f1a7553023a9443
7
- data.tar.gz: 4d71a4831e946faf207b9c21947531f3bc6263941f07e3a627c2e504a8c471d6aec597fe167090c0d0d49979b3b4dbf2f8e0620cfe9cd12f492286f60b5a3e7a
6
+ metadata.gz: 3ebb477674d48c4e4e0aafa30eeb9ca3ad20dd96c65f19d38d31b55f6256b7e816768b149720662c9de239242a9aeab78ed038685dd0d82754b005c9e31380c8
7
+ data.tar.gz: b04b8248df32a42c41b833920cada319fcfb73344a7fa9520867b59fbba60655716f8152342f9a34765c06aa05d99160b7ff9812b826ff7bebca2ccd6a1cda94
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.14.0"`) until the 1.0 release.
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.15.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](http://opensource.org/licenses/MIT).
136
+ Treaty is available as open source under the terms of the [MIT License](./LICENSE).
@@ -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:
@@ -62,6 +63,18 @@ en:
62
63
  conversion_not_supported: "Option 'cast' for attribute '%{attribute}' does not support conversion from '%{from}' to '%{to}'"
63
64
  conversion_error: "Cast failed for attribute '%{attribute}' from '%{from}' to '%{to}'. Value: '%{value}'. Error: %{error}"
64
65
 
66
+ # Attribute conditionals
67
+ conditionals:
68
+ if:
69
+ invalid_type: "Option 'if' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
70
+ evaluation_error: "Conditional evaluation failed for attribute '%{attribute}': %{error}"
71
+
72
+ unless:
73
+ invalid_type: "Option 'unless' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
74
+ evaluation_error: "Conditional evaluation failed for attribute '%{attribute}': %{error}"
75
+
76
+ mutual_exclusivity_error: "Attribute '%{attribute}' cannot have both 'if' and 'unless' options. Use only one conditional option."
77
+
65
78
  # Attribute builder DSL
66
79
  builder:
67
80
  not_implemented: "%{class} must implement #create_attribute"
@@ -143,10 +143,12 @@ module Treaty
143
143
 
144
144
  # Resolves custom message with lambda support
145
145
  # If message is a lambda, calls it with provided named arguments
146
+ # Catches all exceptions from lambda execution and re-raises as Validation errors
146
147
  #
147
148
  # @param attributes [Hash] Named arguments to pass to lambda
148
149
  # @return [String, nil] Resolved message string or nil
149
- def resolve_custom_message(**attributes)
150
+ # @raise [Treaty::Exceptions::Validation] If custom message lambda raises an exception
151
+ def resolve_custom_message(**attributes) # rubocop:disable Metrics/MethodLength
150
152
  message = custom_message
151
153
  return nil if message.nil?
152
154
 
@@ -155,6 +157,15 @@ module Treaty
155
157
  else
156
158
  message
157
159
  end
160
+ rescue StandardError => e
161
+ # Catch all exceptions from custom message lambda execution
162
+ error_message = I18n.t(
163
+ "treaty.attributes.options.message_evaluation_error",
164
+ attribute: @attribute_name,
165
+ error: e.message
166
+ )
167
+
168
+ raise Treaty::Exceptions::Validation, error_message
158
169
  end
159
170
 
160
171
  # 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
@@ -3,7 +3,7 @@
3
3
  module Treaty
4
4
  module Attribute
5
5
  module Option
6
- # Central registry for all option processors (validators and modifiers).
6
+ # Central registry for all option processors (validators, modifiers, and conditionals).
7
7
  #
8
8
  # ## Purpose
9
9
  #
@@ -14,7 +14,7 @@ module Treaty
14
14
  #
15
15
  # 1. **Registration** - Stores option processor classes
16
16
  # 2. **Retrieval** - Provides access to registered processors
17
- # 3. **Categorization** - Organizes processors by category (validator/modifier)
17
+ # 3. **Categorization** - Organizes processors by category (validator/modifier/conditional)
18
18
  # 4. **Validation** - Checks if options are registered
19
19
  #
20
20
  # ## Registered Options
@@ -23,15 +23,23 @@ module Treaty
23
23
  # - `:required` → RequiredValidator
24
24
  # - `:type` → TypeValidator
25
25
  # - `:inclusion` → InclusionValidator
26
+ # - `:format` → FormatValidator
26
27
  #
27
28
  # ### Modifiers
28
29
  # - `:as` → AsModifier
29
30
  # - `:default` → DefaultModifier
31
+ # - `:transform` → TransformModifier
32
+ # - `:cast` → CastModifier
33
+ #
34
+ # ### Conditionals
35
+ # - `:if` → IfConditional
36
+ # - `:unless` → UnlessConditional
30
37
  #
31
38
  # ## Usage
32
39
  #
33
40
  # Registration (done in RegistryInitializer):
34
41
  # Registry.register(:required, RequiredValidator, category: :validator)
42
+ # Registry.register(:if, IfConditional, category: :conditional)
35
43
  #
36
44
  # Retrieval (done in OptionOrchestrator):
37
45
  # processor_class = Registry.processor_for(:required)
@@ -111,6 +119,14 @@ module Treaty
111
119
  .transform_values { |info| info.fetch(:processor_class) }
112
120
  end
113
121
 
122
+ # Get all conditionals
123
+ #
124
+ # @return [Hash] Hash of option_name => processor_class for conditionals
125
+ def conditionals
126
+ registry.select { |_, info| info.fetch(:category) == :conditional }
127
+ .transform_values { |info| info.fetch(:processor_class) }
128
+ end
129
+
114
130
  # Reset registry (mainly for testing)
115
131
  def reset!
116
132
  @registry = nil
@@ -7,14 +7,15 @@ module Treaty
7
7
  #
8
8
  # ## Purpose
9
9
  #
10
- # Centralized registration point for all option processors (validators and modifiers).
10
+ # Centralized registration point for all option processors (validators, modifiers, and conditionals).
11
11
  # Automatically registers all built-in options when loaded.
12
12
  #
13
13
  # ## Responsibilities
14
14
  #
15
15
  # 1. **Validator Registration** - Registers all built-in validators
16
16
  # 2. **Modifier Registration** - Registers all built-in modifiers
17
- # 3. **Auto-Loading** - Executes automatically when file is loaded
17
+ # 3. **Conditional Registration** - Registers all built-in conditionals
18
+ # 4. **Auto-Loading** - Executes automatically when file is loaded
18
19
  #
19
20
  # ## Built-in Validators
20
21
  #
@@ -30,6 +31,11 @@ module Treaty
30
31
  # - `:transform` → TransformModifier - Transforms values using custom lambdas
31
32
  # - `:cast` → CastModifier - Converts values between types automatically
32
33
  #
34
+ # ## Built-in Conditionals
35
+ #
36
+ # - `:if` → IfConditional - Conditionally includes attributes based on runtime data
37
+ # - `:unless` → UnlessConditional - Conditionally excludes attributes based on runtime data
38
+ #
33
39
  # ## Auto-Registration
34
40
  #
35
41
  # This file calls `register_all!` when loaded, ensuring all processors
@@ -63,6 +69,7 @@ module Treaty
63
69
  def register_all!
64
70
  register_validators!
65
71
  register_modifiers!
72
+ register_conditionals!
66
73
  end
67
74
 
68
75
  private
@@ -86,6 +93,14 @@ module Treaty
86
93
  Registry.register(:transform, Modifiers::TransformModifier, category: :modifier)
87
94
  Registry.register(:cast, Modifiers::CastModifier, category: :modifier)
88
95
  end
96
+
97
+ # Registers all built-in conditionals
98
+ #
99
+ # @return [void]
100
+ def register_conditionals!
101
+ Registry.register(:if, Conditionals::IfConditional, category: :conditional)
102
+ Registry.register(:unless, Conditionals::UnlessConditional, category: :conditional)
103
+ end
89
104
  end
90
105
  end
91
106
  end
@@ -79,6 +79,9 @@ module Treaty
79
79
  transformed = {}
80
80
 
81
81
  attribute.collection_of_attributes.each do |nested_attribute|
82
+ # Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
83
+ next unless should_process_attribute?(nested_attribute, value)
84
+
82
85
  process_attribute(nested_attribute, value, transformed)
83
86
  end
84
87
 
@@ -87,6 +90,87 @@ module Treaty
87
90
 
88
91
  private
89
92
 
93
+ # Returns the conditional option name if present (:if or :unless)
94
+ # Raises error if both are present (mutual exclusivity)
95
+ #
96
+ # @param nested_attribute [Attribute::Base] The attribute to check
97
+ # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
98
+ # @return [Symbol, nil] :if, :unless, or nil
99
+ def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength
100
+ has_if = nested_attribute.options.key?(:if)
101
+ has_unless = nested_attribute.options.key?(:unless)
102
+
103
+ if has_if && has_unless
104
+ raise Treaty::Exceptions::Validation,
105
+ I18n.t(
106
+ "treaty.attributes.conditionals.mutual_exclusivity_error",
107
+ attribute: nested_attribute.name
108
+ )
109
+ end
110
+
111
+ return :if if has_if
112
+ return :unless if has_unless
113
+
114
+ nil
115
+ end
116
+
117
+ # Gets cached conditional processors for attributes or builds them
118
+ #
119
+ # @return [Hash] Hash of attribute => conditional processor
120
+ def conditionals_for_attributes
121
+ @conditionals_for_attributes ||= build_conditionals_for_attributes
122
+ end
123
+
124
+ # Builds conditional processors for attributes with :if or :unless option
125
+ # Validates schema at definition time for performance
126
+ #
127
+ # @return [Hash] Hash of attribute => conditional processor
128
+ def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
129
+ attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache|
130
+ # Get conditional option name (:if or :unless)
131
+ conditional_type = conditional_option_for(nested_attribute)
132
+ next if conditional_type.nil?
133
+
134
+ processor_class = Option::Registry.processor_for(conditional_type)
135
+ next if processor_class.nil?
136
+
137
+ # Create processor instance
138
+ conditional = processor_class.new(
139
+ attribute_name: nested_attribute.name,
140
+ attribute_type: nested_attribute.type,
141
+ option_schema: nested_attribute.options.fetch(conditional_type)
142
+ )
143
+
144
+ # Validate schema at definition time (not runtime)
145
+ conditional.validate_schema!
146
+
147
+ cache[nested_attribute] = conditional
148
+ end
149
+ end
150
+
151
+ # Checks if an attribute should be processed based on its conditional (if/unless option)
152
+ # Returns true if no conditional is defined or if conditional evaluates appropriately
153
+ #
154
+ # @param nested_attribute [Attribute::Base] The attribute to check
155
+ # @param source_hash [Hash] Source data to pass to conditional
156
+ # @return [Boolean] True if attribute should be processed, false to skip it
157
+ def should_process_attribute?(nested_attribute, source_hash)
158
+ # Check if attribute has a conditional option
159
+ conditional_type = conditional_option_for(nested_attribute)
160
+ return true if conditional_type.nil?
161
+
162
+ # Get cached conditional processor
163
+ conditional = conditionals_for_attributes[nested_attribute]
164
+ return true if conditional.nil?
165
+
166
+ # Evaluate condition with source hash data wrapped with parent object name
167
+ wrapped_data = { attribute.name => source_hash }
168
+ conditional.evaluate_condition(wrapped_data)
169
+ rescue StandardError
170
+ # If conditional evaluation fails, skip the attribute
171
+ false
172
+ end
173
+
90
174
  # Processes a single nested attribute
91
175
  # Validates, transforms, and adds to target hash
92
176
  #
@@ -117,7 +201,7 @@ module Treaty
117
201
  end
118
202
 
119
203
  # Transforms array with nested attributes
120
- class ArrayTransformer
204
+ class ArrayTransformer # rubocop:disable Metrics/ClassLength
121
205
  SELF_OBJECT = :_self
122
206
  private_constant :SELF_OBJECT
123
207
 
@@ -147,6 +231,87 @@ module Treaty
147
231
 
148
232
  private
149
233
 
234
+ # Returns the conditional option name if present (:if or :unless)
235
+ # Raises error if both are present (mutual exclusivity)
236
+ #
237
+ # @param nested_attribute [Attribute::Base] The attribute to check
238
+ # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
239
+ # @return [Symbol, nil] :if, :unless, or nil
240
+ def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength
241
+ has_if = nested_attribute.options.key?(:if)
242
+ has_unless = nested_attribute.options.key?(:unless)
243
+
244
+ if has_if && has_unless
245
+ raise Treaty::Exceptions::Validation,
246
+ I18n.t(
247
+ "treaty.attributes.conditionals.mutual_exclusivity_error",
248
+ attribute: nested_attribute.name
249
+ )
250
+ end
251
+
252
+ return :if if has_if
253
+ return :unless if has_unless
254
+
255
+ nil
256
+ end
257
+
258
+ # Gets cached conditional processors for attributes or builds them
259
+ #
260
+ # @return [Hash] Hash of attribute => conditional processor
261
+ def conditionals_for_attributes
262
+ @conditionals_for_attributes ||= build_conditionals_for_attributes
263
+ end
264
+
265
+ # Builds conditional processors for attributes with :if or :unless option
266
+ # Validates schema at definition time for performance
267
+ #
268
+ # @return [Hash] Hash of attribute => conditional processor
269
+ def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
270
+ attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache|
271
+ # Get conditional option name (:if or :unless)
272
+ conditional_type = conditional_option_for(nested_attribute)
273
+ next if conditional_type.nil?
274
+
275
+ processor_class = Option::Registry.processor_for(conditional_type)
276
+ next if processor_class.nil?
277
+
278
+ # Create processor instance
279
+ conditional = processor_class.new(
280
+ attribute_name: nested_attribute.name,
281
+ attribute_type: nested_attribute.type,
282
+ option_schema: nested_attribute.options.fetch(conditional_type)
283
+ )
284
+
285
+ # Validate schema at definition time (not runtime)
286
+ conditional.validate_schema!
287
+
288
+ cache[nested_attribute] = conditional
289
+ end
290
+ end
291
+
292
+ # Checks if an attribute should be processed based on its conditional (if/unless option)
293
+ # Returns true if no conditional is defined or if conditional evaluates appropriately
294
+ #
295
+ # @param nested_attribute [Attribute::Base] The attribute to check
296
+ # @param source_hash [Hash] Source data to pass to conditional
297
+ # @return [Boolean] True if attribute should be processed, false to skip it
298
+ def should_process_attribute?(nested_attribute, source_hash)
299
+ # Check if attribute has a conditional option
300
+ conditional_type = conditional_option_for(nested_attribute)
301
+ return true if conditional_type.nil?
302
+
303
+ # Get cached conditional processor
304
+ conditional = conditionals_for_attributes[nested_attribute]
305
+ return true if conditional.nil?
306
+
307
+ # Evaluate condition with source hash data wrapped with parent array attribute name
308
+ wrapped_data = { attribute.name => source_hash }
309
+ conditional.evaluate_condition(wrapped_data)
310
+ rescue StandardError
311
+ # If conditional evaluation fails, skip the attribute
312
+ false
313
+ end
314
+
150
315
  # Checks if this is a simple array (primitive values)
151
316
  #
152
317
  # @return [Boolean] True if array contains primitive values with :_self attribute
@@ -163,8 +328,8 @@ module Treaty
163
328
  # @raise [Treaty::Exceptions::Validation] If validation fails
164
329
  # @return [Object] Transformed element value
165
330
  def transform_simple_element(item, index) # rubocop:disable Metrics/MethodLength
166
- self_attr = attribute.collection_of_attributes.first
167
- validator = AttributeValidator.new(self_attr)
331
+ self_attribute = attribute.collection_of_attributes.first
332
+ validator = AttributeValidator.new(self_attribute)
168
333
  validator.validate_schema!
169
334
 
170
335
  begin
@@ -201,6 +366,9 @@ module Treaty
201
366
  transformed = {}
202
367
 
203
368
  attribute.collection_of_attributes.each do |nested_attribute|
369
+ # Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
370
+ next unless should_process_attribute?(nested_attribute, item)
371
+
204
372
  process_attribute(nested_attribute, item, transformed, index)
205
373
  end
206
374
 
@@ -70,12 +70,16 @@ module Treaty
70
70
 
71
71
  # Validates and transforms all attributes
72
72
  # Iterates through attributes, processes them, handles :_self objects
73
+ # Skips attributes with false conditional (if/unless option)
73
74
  #
74
75
  # @return [Hash] Transformed data with all attributes processed
75
- def validate!
76
+ def validate! # rubocop:disable Metrics/MethodLength
76
77
  transformed_data = {}
77
78
 
78
79
  collection_of_attributes.each do |attribute|
80
+ # Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
81
+ next unless should_process_attribute?(attribute)
82
+
79
83
  transformed_value = validate_and_transform_attribute!(attribute)
80
84
 
81
85
  if attribute.name == SELF_OBJECT && attribute.type == :object
@@ -91,6 +95,49 @@ module Treaty
91
95
 
92
96
  private
93
97
 
98
+ # Returns the conditional option name if present (:if or :unless)
99
+ # Raises error if both are present (mutual exclusivity)
100
+ #
101
+ # @param attribute [Attribute::Base] The attribute to check
102
+ # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
103
+ # @return [Symbol, nil] :if, :unless, or nil
104
+ def conditional_option_for(attribute) # rubocop:disable Metrics/MethodLength
105
+ has_if = attribute.options.key?(:if)
106
+ has_unless = attribute.options.key?(:unless)
107
+
108
+ if has_if && has_unless
109
+ raise Treaty::Exceptions::Validation,
110
+ I18n.t(
111
+ "treaty.attributes.conditionals.mutual_exclusivity_error",
112
+ attribute: attribute.name
113
+ )
114
+ end
115
+
116
+ return :if if has_if
117
+ return :unless if has_unless
118
+
119
+ nil
120
+ end
121
+
122
+ # Checks if an attribute should be processed based on its conditional (if/unless option)
123
+ # Returns true if no conditional is defined or if conditional evaluates appropriately
124
+ #
125
+ # @param attribute [Attribute::Base] The attribute to check
126
+ # @return [Boolean] True if attribute should be processed, false to skip it
127
+ def should_process_attribute?(attribute)
128
+ # Check if attribute has a conditional option
129
+ conditional_type = conditional_option_for(attribute)
130
+ return true if conditional_type.nil?
131
+
132
+ # Get cached conditional processor
133
+ conditional = conditionals_for_attributes[attribute]
134
+ return true if conditional.nil?
135
+
136
+ # Evaluate condition with raw data
137
+ # The processor's evaluate_condition already handles if/unless logic
138
+ conditional.evaluate_condition(data)
139
+ end
140
+
94
141
  # Returns collection of attributes for this context
95
142
  # Must be implemented in subclasses
96
143
  #
@@ -119,6 +166,40 @@ module Treaty
119
166
  end
120
167
  end
121
168
 
169
+ # Gets cached conditional processors for attributes or builds them
170
+ #
171
+ # @return [Hash] Hash of attribute => conditional processor
172
+ def conditionals_for_attributes
173
+ @conditionals_for_attributes ||= build_conditionals_for_attributes
174
+ end
175
+
176
+ # Builds conditional processors for attributes with :if or :unless option
177
+ # Validates schema at definition time for performance
178
+ #
179
+ # @return [Hash] Hash of attribute => conditional processor
180
+ def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
181
+ collection_of_attributes.each_with_object({}) do |attribute, cache|
182
+ # Get conditional option name (:if or :unless)
183
+ conditional_type = conditional_option_for(attribute)
184
+ next if conditional_type.nil?
185
+
186
+ processor_class = Option::Registry.processor_for(conditional_type)
187
+ next if processor_class.nil?
188
+
189
+ # Create processor instance
190
+ conditional = processor_class.new(
191
+ attribute_name: attribute.name,
192
+ attribute_type: attribute.type,
193
+ option_schema: attribute.options.fetch(conditional_type)
194
+ )
195
+
196
+ # Validate schema at definition time (not runtime)
197
+ conditional.validate_schema!
198
+
199
+ cache[attribute] = conditional
200
+ end
201
+ end
202
+
122
203
  # Validates and transforms a single attribute
123
204
  # Handles both nested and regular attributes
124
205
  #
data/lib/treaty/result.rb CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  module Treaty
4
4
  class Result
5
- attr_reader :data, :status
5
+ attr_reader :data, :status, :version
6
6
 
7
- def initialize(data:, status:)
7
+ def initialize(data:, status:, version:)
8
8
  @data = data
9
9
  @status = status
10
+ @version = version
10
11
  end
11
12
 
12
13
  def inspect
@@ -16,7 +17,7 @@ module Treaty
16
17
  private
17
18
 
18
19
  def draw_result
19
- "@data=#{@data.inspect}, @status=#{@status.inspect}"
20
+ "@data=#{@data.inspect}, @status=#{@status.inspect}, @version=#{@version.inspect}"
20
21
  end
21
22
  end
22
23
  end
@@ -3,7 +3,7 @@
3
3
  module Treaty
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 14
6
+ MINOR = 15
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
@@ -34,7 +34,8 @@ module Treaty
34
34
 
35
35
  Treaty::Result.new(
36
36
  data: validated_response,
37
- status:
37
+ status:,
38
+ version: version_factory.version.version
38
39
  )
39
40
  end
40
41
  end
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.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Sokolov
@@ -155,6 +155,9 @@ files:
155
155
  - lib/treaty/attribute/entity/builder.rb
156
156
  - lib/treaty/attribute/helper_mapper.rb
157
157
  - lib/treaty/attribute/option/base.rb
158
+ - lib/treaty/attribute/option/conditionals/base.rb
159
+ - lib/treaty/attribute/option/conditionals/if_conditional.rb
160
+ - lib/treaty/attribute/option/conditionals/unless_conditional.rb
158
161
  - lib/treaty/attribute/option/modifiers/as_modifier.rb
159
162
  - lib/treaty/attribute/option/modifiers/cast_modifier.rb
160
163
  - lib/treaty/attribute/option/modifiers/default_modifier.rb