servactory 3.1.0.rc2 → 3.1.0.rc4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +7 -2
  4. data/config/locales/ja.yml +134 -0
  5. data/lib/generators/README.md +2 -2
  6. data/lib/generators/servactory/extension/USAGE +1 -1
  7. data/lib/generators/servactory/install/USAGE +2 -2
  8. data/lib/generators/servactory/rspec/USAGE +4 -4
  9. data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
  10. data/lib/generators/servactory/service/USAGE +5 -5
  11. data/lib/servactory/actions/collection.rb +21 -5
  12. data/lib/servactory/actions/stages/collection.rb +19 -1
  13. data/lib/servactory/actions/tools/rules.rb +1 -1
  14. data/lib/servactory/actions/tools/runner.rb +1 -1
  15. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
  16. data/lib/servactory/configuration/config.rb +16 -0
  17. data/lib/servactory/configuration/configurable.rb +7 -4
  18. data/lib/servactory/configuration/factory.rb +30 -3
  19. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -1
  20. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +156 -29
  21. data/lib/servactory/context/warehouse/inputs.rb +1 -1
  22. data/lib/servactory/context/workspace/inputs.rb +2 -2
  23. data/lib/servactory/context/workspace/internals.rb +2 -2
  24. data/lib/servactory/context/workspace/outputs.rb +2 -2
  25. data/lib/servactory/context/workspace.rb +7 -7
  26. data/lib/servactory/info/builder.rb +4 -3
  27. data/lib/servactory/inputs/dsl.rb +5 -1
  28. data/lib/servactory/inputs/input.rb +1 -2
  29. data/lib/servactory/inputs/tools/validation.rb +10 -18
  30. data/lib/servactory/internals/dsl.rb +7 -1
  31. data/lib/servactory/maintenance/attributes/base.rb +4 -1
  32. data/lib/servactory/maintenance/attributes/collection.rb +61 -6
  33. data/lib/servactory/maintenance/options/collection.rb +71 -30
  34. data/lib/servactory/maintenance/options/option.rb +3 -12
  35. data/lib/servactory/maintenance/options/registrar.rb +3 -13
  36. data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
  37. data/lib/servactory/maintenance/validations/performer.rb +12 -23
  38. data/lib/servactory/outputs/dsl.rb +7 -1
  39. data/lib/servactory/result.rb +21 -8
  40. data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
  41. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
  42. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -125
  43. data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
  44. data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
  45. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +185 -50
  46. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +45 -10
  47. data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
  48. data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
  49. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
  50. data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
  51. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
  52. data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
  53. data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
  54. data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
  55. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
  56. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
  57. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
  58. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
  59. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
  60. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
  61. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
  62. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
  63. data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
  64. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +34 -11
  65. data/lib/servactory/tool_kit/dynamic_options/format.rb +3 -4
  66. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +3 -3
  67. data/lib/servactory/tool_kit/dynamic_options/max.rb +3 -3
  68. data/lib/servactory/tool_kit/dynamic_options/min.rb +3 -3
  69. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +35 -7
  70. data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
  71. data/lib/servactory/tool_kit/dynamic_options/schema.rb +40 -25
  72. data/lib/servactory/tool_kit/dynamic_options/target.rb +4 -4
  73. data/lib/servactory/utils.rb +13 -3
  74. data/lib/servactory/version.rb +1 -1
  75. data/lib/servactory.rb +3 -4
  76. metadata +20 -4
@@ -28,18 +28,16 @@ module Servactory
28
28
  # ## Performance
29
29
  #
30
30
  # The collection uses memoization for frequently accessed data:
31
- # - `validation_classes` - cached list of unique validation classes
32
- # - `options_for_checks` - cached hash for validation pipeline
31
+ # - `validations_for_checks` - cached tuples for validation pipeline
33
32
  # - `options_index` - cached hash for O(1) lookups by name
34
33
  #
35
34
  class Collection
36
35
  extend Forwardable
37
36
 
38
37
  def_delegators :@collection,
39
- :<<,
40
38
  :filter,
41
- :each_with_object,
42
- :map, :flat_map,
39
+ :each, :each_with_object,
40
+ :map,
43
41
  :size,
44
42
  :empty?
45
43
 
@@ -50,6 +48,26 @@ module Servactory
50
48
  @collection = Set.new
51
49
  end
52
50
 
51
+ # Duplicates the collection, resetting memoized caches.
52
+ #
53
+ # @param original [Collection] the collection being duplicated
54
+ # @return [void]
55
+ def initialize_dup(original)
56
+ super
57
+ @collection = original.instance_variable_get(:@collection).dup
58
+ reset_memoized_caches
59
+ end
60
+
61
+ # Adds an option to the collection, invalidating memoized caches.
62
+ #
63
+ # @param option [Option] the option to add
64
+ # @return [Collection] self
65
+ def <<(option)
66
+ @collection << option
67
+ reset_memoized_caches
68
+ self
69
+ end
70
+
53
71
  # Returns all option names in the collection.
54
72
  #
55
73
  # @return [Array<Symbol>] list of option names
@@ -57,32 +75,51 @@ module Servactory
57
75
  map(&:name)
58
76
  end
59
77
 
60
- # Returns unique validation classes from all options.
78
+ # @deprecated Use {#validations_for_checks} instead.
61
79
  #
62
80
  # @return [Array<Class>] deduplicated list of validation classes
63
81
  def validation_classes
64
- @validation_classes ||=
65
- filter { |option| option.validation_class.present? }
66
- .map(&:validation_class)
67
- .uniq
82
+ warn "[DEPRECATION] Servactory::Maintenance::Options::Collection#validation_classes is deprecated. " \
83
+ "Use #validations_for_checks instead."
84
+ validations_for_checks.map(&:last).uniq
68
85
  end
69
86
 
70
- # Returns options that need validation checks as a hash.
87
+ # @deprecated Use {#validations_for_checks} instead.
71
88
  #
72
89
  # @return [Hash{Symbol => Object}] option names mapped to normalized bodies
73
90
  def options_for_checks
74
- @options_for_checks ||= filter(&:need_for_checks?).to_h do |option|
75
- [option.name, extract_normalized_body_from(option:)]
91
+ warn "[DEPRECATION] Servactory::Maintenance::Options::Collection#options_for_checks is deprecated. " \
92
+ "Use #validations_for_checks instead."
93
+ check_options_by_key
94
+ end
95
+
96
+ # Returns options that need validation checks as an array of tuples.
97
+ # Each tuple contains [check_key, check_options, validation_class],
98
+ # enabling direct dispatch without nested iteration.
99
+ #
100
+ # @return [Array<Array(Symbol, Object, Class)>] tuples for direct validation dispatch
101
+ def validations_for_checks
102
+ @validations_for_checks ||= filter(&:need_for_checks?).filter_map do |option|
103
+ next if option.validation_class.nil?
104
+
105
+ [option.name, extract_normalized_body_from(option:), option.validation_class]
76
106
  end
77
107
  end
78
108
 
79
109
  # Returns the first conflict code found among options.
80
110
  #
81
- # @return [Object, nil] conflict code or nil if no conflicts
111
+ # @return [Symbol, nil] conflict code or nil if no conflicts
82
112
  def defined_conflict_code
83
- flat_map { |option| resolve_conflicts_from(option:) }
84
- .reject(&:blank?)
85
- .first
113
+ each do |option|
114
+ next unless option.define_conflicts
115
+
116
+ option.define_conflicts.each do |conflict|
117
+ code = conflict.content.call
118
+ return code if code.present?
119
+ end
120
+ end
121
+
122
+ nil
86
123
  end
87
124
 
88
125
  # Finds an option by its name using indexed lookup.
@@ -95,13 +132,27 @@ module Servactory
95
132
 
96
133
  private
97
134
 
135
+ # Builds the result of the deprecated `options_for_checks` methods
136
+ # without emitting a deprecation warning.
137
+ #
138
+ # @return [Hash{Symbol => Object}] option names mapped to normalized bodies
139
+ def check_options_by_key
140
+ validations_for_checks.to_h { |check_key, check_options, _| [check_key, check_options] }
141
+ end
142
+
143
+ # Resets memoized data derived from the collection contents.
144
+ #
145
+ # @return [void]
146
+ def reset_memoized_caches
147
+ @validations_for_checks = nil
148
+ @options_index = nil
149
+ end
150
+
98
151
  # Builds and caches a hash index for O(1) option lookups.
99
152
  #
100
153
  # @return [Hash{Symbol => Option}] option names mapped to Option instances
101
154
  def options_index
102
- @options_index ||= each_with_object({}) do |option, index|
103
- index[option.name] = option
104
- end
155
+ @options_index ||= @collection.to_h { |option| [option.name, option] }
105
156
  end
106
157
 
107
158
  # Extracts the normalized body value from an option.
@@ -111,16 +162,6 @@ module Servactory
111
162
  def extract_normalized_body_from(option:)
112
163
  option.value
113
164
  end
114
-
115
- # Resolves conflict codes from an option's define_conflicts.
116
- #
117
- # @param option [Option] the option to check for conflicts
118
- # @return [Array<Object>] array of conflict codes (may contain nils/blanks)
119
- def resolve_conflicts_from(option:)
120
- return [] unless option.define_conflicts
121
-
122
- option.define_conflicts.map { |conflict| conflict.content.call }
123
- end
124
165
  end
125
166
  end
126
167
  end
@@ -17,7 +17,7 @@ module Servactory
17
17
  :body_key,
18
18
  :return_value_on_access
19
19
 
20
- # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
20
+ # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists
21
21
  def initialize(
22
22
  name:,
23
23
  attribute:,
@@ -54,7 +54,6 @@ module Servactory
54
54
 
55
55
  apply_dynamic_methods_to(attribute:)
56
56
  end
57
- # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
58
57
 
59
58
  def value
60
59
  return body unless body.is_a?(Hash)
@@ -72,7 +71,7 @@ module Servactory
72
71
 
73
72
  private
74
73
 
75
- # rubocop:disable Metrics/MethodLength
74
+ # rubocop:disable-next Metrics/MethodLength
76
75
  def construct_body(
77
76
  original_value:,
78
77
  options:,
@@ -81,9 +80,7 @@ module Servactory
81
80
  body_fallback:,
82
81
  detect_advanced_mode:
83
82
  )
84
- return wrap_and_normalize(original_value) if original_value.present?
85
-
86
- raw_value = options.fetch(@name, body_fallback)
83
+ raw_value = original_value.nil? ? options.fetch(@name, body_fallback) : original_value
87
84
 
88
85
  result = if detect_advanced_mode
89
86
  use_advanced_mode(
@@ -98,12 +95,6 @@ module Servactory
98
95
 
99
96
  apply_normalizer(result)
100
97
  end
101
- # rubocop:enable Metrics/MethodLength
102
-
103
- def wrap_and_normalize(value)
104
- result = wrap_value(value)
105
- apply_normalizer(result)
106
- end
107
98
 
108
99
  def apply_normalizer(body)
109
100
  return body unless @normalizer && body.is_a?(Hash)
@@ -78,9 +78,10 @@ module Servactory
78
78
  create_option(
79
79
  name: :types,
80
80
  validation_class:,
81
- original_value: extract_types_value,
81
+ original_value: @options[:type],
82
82
  need_for_checks: true,
83
83
  body_key: :is,
84
+ body_value: nil,
84
85
  body_fallback: nil,
85
86
  detect_advanced_mode: true,
86
87
  return_value_on_access: true,
@@ -98,7 +99,7 @@ module Servactory
98
99
  content: ->(option:) { !option[:is].nil? }
99
100
  )
100
101
  ],
101
- need_for_checks: true,
102
+ need_for_checks: false,
102
103
  body_key: :is,
103
104
  body_fallback: nil,
104
105
  detect_advanced_mode: false,
@@ -142,17 +143,6 @@ module Servactory
142
143
  )
143
144
  end
144
145
 
145
- def extract_types_value
146
- type_option = @options[:type]
147
- return nil if type_option.nil?
148
-
149
- # Advanced Mode: type: { is: String, message: "..." }
150
- return nil if type_option.is_a?(Hash) && type_option.key?(:is)
151
-
152
- # Simple Mode: type: String or type: [String, Integer]
153
- Array(type_option).uniq
154
- end
155
-
156
146
  ########################################################################
157
147
 
158
148
  def required_define_methods
@@ -21,11 +21,19 @@ module Servactory
21
21
  # input :id, type: Integer
22
22
  # input :data, type: [Hash, Array] # union type
23
23
  # input :status, type: String, required: false
24
+ # input :email, type: { is: String, message: "Email must be a String" }
24
25
  #
25
26
  # internal :result, type: CustomType
26
27
  # output :response, type: Hash
27
28
  # end
28
29
  # ```
30
+ #
31
+ # ## Custom Message
32
+ #
33
+ # Advanced mode `type: { is:, message: }` replaces the default error
34
+ # message for every type failure of the attribute. A Proc message
35
+ # receives `service:`, `input:`/`internal:`/`output:`, `value:`,
36
+ # `expected_type:` and `given_type:`.
29
37
  class Type
30
38
  extend Concerns::ErrorBuilder
31
39
 
@@ -51,7 +59,7 @@ module Servactory
51
59
 
52
60
  return if error_data.nil?
53
61
 
54
- build_error_message(error_data)
62
+ build_error_message(attribute:, error_data:)
55
63
  end
56
64
 
57
65
  # Determines if type validation should run for given attribute.
@@ -67,7 +75,7 @@ module Servactory
67
75
  # @param value [Object] Current value (for optional input check)
68
76
  # @param check_key [Symbol] Current validation check key
69
77
  # @return [Boolean] true if type validation should run
70
- # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
78
+ # rubocop:disable-next Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
71
79
  def self.should_be_checked_for?(attribute, value, check_key)
72
80
  check_key == :types && (
73
81
  (
@@ -81,7 +89,6 @@ module Servactory
81
89
  ) || attribute.internal? || attribute.output?
82
90
  )
83
91
  end
84
- # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
85
92
  private_class_method :should_be_checked_for?
86
93
 
87
94
  # Computes prepared value with default substitution for optional inputs.
@@ -100,10 +107,21 @@ module Servactory
100
107
 
101
108
  # Builds error message from validation error data.
102
109
  #
110
+ # Uses the custom message from `type: { is:, message: }` if provided,
111
+ # otherwise falls back to the default message.
112
+ #
113
+ # @param attribute [Inputs::Input, Internals::Internal, Outputs::Output] Failed attribute
103
114
  # @param error_data [Hash] Error data from TypeValidator.validate
104
115
  # @return [String] Processed error message
105
- def self.build_error_message(error_data)
106
- process_message(error_data[:message], **error_data)
116
+ def self.build_error_message(attribute:, error_data:)
117
+ message = attribute.collection_of_options.find_by(name: :types).body[:message]
118
+ return process_message(error_data[:message], **error_data) if message.blank?
119
+
120
+ process_message(
121
+ message,
122
+ **attribute.typed_actor_kwargs,
123
+ **error_data.slice(:service, :value, :expected_type, :given_type)
124
+ )
107
125
  end
108
126
  private_class_method :build_error_message
109
127
  end
@@ -19,14 +19,11 @@ module Servactory
19
19
 
20
20
  private
21
21
 
22
- def process(context:, attribute:, value:) # rubocop:disable Metrics/MethodLength
23
- attribute.options_for_checks.each do |check_key, check_options|
22
+ def process(context:, attribute:, value:)
23
+ attribute.collection_of_options.validations_for_checks.each do |check_key, check_options, validation_class|
24
24
  error = process_option(
25
- context:,
26
- attribute:,
27
- value:,
28
- check_key:,
29
- check_options:
25
+ context:, attribute:, value:,
26
+ check_key:, check_options:, validation_class:
30
27
  )
31
28
  return error if error.present?
32
29
  end
@@ -34,22 +31,14 @@ module Servactory
34
31
  nil
35
32
  end
36
33
 
37
- def process_option(context:, attribute:, value:, check_key:, check_options:) # rubocop:disable Metrics/MethodLength
38
- validation_classes = attribute.collection_of_options.validation_classes
39
- return if validation_classes.empty?
40
-
41
- validation_classes.each do |validation_class|
42
- error_message = validation_class.check(
43
- context:,
44
- attribute:,
45
- value:,
46
- check_key:,
47
- check_options:
48
- )
49
- return error_message if error_message.present?
50
- end
51
-
52
- nil
34
+ def process_option(context:, attribute:, value:, check_key:, check_options:, validation_class:)
35
+ validation_class.check(
36
+ context:,
37
+ attribute:,
38
+ value:,
39
+ check_key:,
40
+ check_options:
41
+ )
53
42
  end
54
43
  end
55
44
  end
@@ -17,12 +17,18 @@ module Servactory
17
17
  private
18
18
 
19
19
  def output(name, *helpers, **options)
20
- collection_of_outputs << Output.new(
20
+ output = Output.new(
21
21
  name,
22
22
  *helpers,
23
23
  option_helpers: config.output_option_helpers,
24
24
  **options
25
25
  )
26
+
27
+ if output.types.empty?
28
+ raise ArgumentError, "[#{self.name}] Output attribute `#{name}` must have the `type` option"
29
+ end
30
+
31
+ collection_of_outputs << output
26
32
  end
27
33
 
28
34
  def collection_of_outputs
@@ -41,7 +41,7 @@ module Servactory
41
41
  # ```ruby
42
42
  # result.active? # Equivalent to Utils.query_attribute(result.active)
43
43
  # ```
44
- class Result
44
+ class Result # rubocop:disable Metrics/ClassLength
45
45
  # Internal container for service output values.
46
46
  #
47
47
  # Provides dynamic method access to output values.
@@ -120,6 +120,10 @@ module Servactory
120
120
 
121
121
  private_constant :Outputs
122
122
 
123
+ # Result state keys exposed by {#deconstruct_keys}.
124
+ STATE_KEYS = %i[success failure error].freeze
125
+ private_constant :STATE_KEYS
126
+
123
127
  ############################################################################
124
128
 
125
129
  # Creates a success result for the given context.
@@ -198,20 +202,27 @@ module Servactory
198
202
 
199
203
  # Converts outputs to hash.
200
204
  #
201
- # Excludes predicate methods from the result hash.
205
+ # Returns only outputs that were assigned during the call, including
206
+ # outputs explicitly assigned nil. Declared outputs that were never
207
+ # assigned are absent. Excludes predicate methods from the result hash.
202
208
  #
203
- # @return [Hash<Symbol, Object>] Output name-value pairs
209
+ # @return [Hash<Symbol, Object>] Assigned output name-value pairs
204
210
  #
205
211
  # @example
206
- # result.to_h # => { user: #<User>, token: "abc123" }
212
+ # result.to_h # => { user: #<User>, token: nil }
207
213
  def to_h
208
- outputs.send(:output_names).to_h { |key| [key, outputs.public_send(key)] }.compact
214
+ hash = {}
215
+ outputs.send(:output_names).each { |key| hash[key] = outputs.public_send(key) }
216
+ hash
209
217
  end
210
218
 
211
219
  # Pattern matching support.
212
220
  #
213
221
  # Returns hash of result state and output values for use with case/in.
214
- # State keys (:success, :failure, :error) take priority over output names.
222
+ # Output keys match {#to_h}: only assigned outputs are present.
223
+ # State keys (:success, :failure, :error) take priority over output names:
224
+ # outputs with these names are not included, and :error is present only
225
+ # for failures.
215
226
  #
216
227
  # @param keys [Array<Symbol>, nil] Keys to include, or nil for all
217
228
  # @return [Hash<Symbol, Object>] Hash of state and outputs for pattern matching
@@ -234,6 +245,8 @@ module Servactory
234
245
  available[:error] = error if failure?
235
246
 
236
247
  outputs.send(:output_names).each do |name|
248
+ next if STATE_KEYS.include?(name)
249
+
237
250
  available[name] = outputs.public_send(name)
238
251
  end
239
252
 
@@ -347,7 +360,7 @@ module Servactory
347
360
  Outputs.new(
348
361
  outputs: build_outputs_hash,
349
362
  predicate_methods_enabled:
350
- @context.is_a?(Servactory::TestKit::Result) || @context.config.predicate_methods_enabled
363
+ @context.is_a?(Servactory::TestKit::Result) || @context.class.config.predicate_methods_enabled
351
364
  )
352
365
  end
353
366
 
@@ -374,7 +387,7 @@ module Servactory
374
387
  def rescue_no_method_error_with(exception:) # rubocop:disable Metrics/MethodLength
375
388
  raise exception if @context.blank? || @context.instance_of?(Servactory::TestKit::Result)
376
389
 
377
- raise @context.config.failure_class.new(
390
+ raise @context.class.config.failure_class.new(
378
391
  type: :base,
379
392
  message: @context.send(:servactory_service_info).translate(
380
393
  "common.undefined_method.missing_name",
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Helpers
7
+ # Validates the exception of a mock configuration.
8
+ #
9
+ # ## Purpose
10
+ #
11
+ # Ensures that failure mocks have an exception and that the exception
12
+ # type matches the service configuration. Called automatically by
13
+ # MockExecutor for every config before stubbing.
14
+ #
15
+ # ## Usage
16
+ #
17
+ # ```ruby
18
+ # ExceptionValidator.validate!(config)
19
+ # ```
20
+ #
21
+ # ## Validation Strategy
22
+ #
23
+ # - `.call` - accepts any Servactory::Exceptions::Failure subclass
24
+ # - `.call!` - requires the service's configured failure_class or its subclass
25
+ class ExceptionValidator
26
+ include Concerns::ErrorMessages
27
+
28
+ class << self
29
+ # Validates a config and raises on failure.
30
+ #
31
+ # @param config [ServiceMockConfig] The config to validate
32
+ # @return [void]
33
+ # @raise [ArgumentError] If config is invalid
34
+ def validate!(config)
35
+ new(config).validate!
36
+ end
37
+ end
38
+
39
+ # Creates a new validator instance.
40
+ #
41
+ # @param config [ServiceMockConfig] The config to validate
42
+ # @return [ExceptionValidator] New validator
43
+ def initialize(config)
44
+ @config = config
45
+ end
46
+
47
+ # Validates a single configuration.
48
+ #
49
+ # @return [void]
50
+ # @raise [ArgumentError] If config is invalid
51
+ def validate!
52
+ validate_failure_has_exception!
53
+ validate_exception_type!
54
+ end
55
+
56
+ private
57
+
58
+ # Validates that failure configs have an exception.
59
+ #
60
+ # @return [void]
61
+ # @raise [ArgumentError] If failure config is missing exception
62
+ def validate_failure_has_exception!
63
+ return unless @config.failure? && @config.exception.nil?
64
+
65
+ raise ArgumentError, missing_exception_for_failure_message(@config.service_class)
66
+ end
67
+
68
+ # Validates that exception is the correct type for the service.
69
+ #
70
+ # @return [void]
71
+ # @raise [ArgumentError] If exception type is wrong
72
+ def validate_exception_type!
73
+ return if @config.exception.nil?
74
+ return if valid_exception_type?
75
+
76
+ raise ArgumentError, invalid_exception_type_message(
77
+ service_class: @config.service_class,
78
+ expected_class: failure_class,
79
+ actual_class: @config.exception.class
80
+ )
81
+ end
82
+
83
+ # Checks if exception is the correct type.
84
+ #
85
+ # Uses different validation strategies based on method type:
86
+ # - For `.call` (non-bang): Relaxed validation - accepts any Servactory::Exceptions::Failure subclass
87
+ # because the exception is wrapped in Result and never raised, so type doesn't matter.
88
+ # - For `.call!` (bang): Strict validation - requires the service's configured failure_class
89
+ # because the exception IS raised and type matters for rescue clauses.
90
+ #
91
+ # @return [Boolean] True if exception is valid type
92
+ def valid_exception_type?
93
+ if @config.bang_method?
94
+ # Strict validation for call! - exception will be raised
95
+ @config.exception.is_a?(failure_class)
96
+ else
97
+ # Relaxed validation for call - exception is only wrapped in Result
98
+ @config.exception.is_a?(Servactory::Exceptions::Failure)
99
+ end
100
+ end
101
+
102
+ # Returns the expected failure class for the service.
103
+ #
104
+ # @return [Class] The service's failure class
105
+ def failure_class
106
+ @config.service_class.config.failure_class
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -133,7 +133,9 @@ module Servactory
133
133
  # @raise [ValidationError] If service has required inputs
134
134
  # @return [void]
135
135
  def validate_service_has_no_required_inputs!
136
- required_inputs = @service_class.info.inputs.reject { |_, v| v[:required] == false }.keys
136
+ required_inputs = @service_class.info.inputs.filter_map do |input_name, input|
137
+ input_name if input.fetch(:actor).required?
138
+ end
137
139
 
138
140
  return if required_inputs.empty?
139
141