servactory 3.1.0.rc3 → 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/lib/generators/README.md +1 -1
  4. data/lib/generators/servactory/extension/USAGE +1 -1
  5. data/lib/generators/servactory/install/USAGE +2 -2
  6. data/lib/generators/servactory/rspec/USAGE +4 -4
  7. data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
  8. data/lib/generators/servactory/service/USAGE +5 -5
  9. data/lib/servactory/actions/collection.rb +21 -5
  10. data/lib/servactory/actions/stages/collection.rb +19 -1
  11. data/lib/servactory/actions/tools/rules.rb +1 -1
  12. data/lib/servactory/actions/tools/runner.rb +1 -1
  13. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
  14. data/lib/servactory/configuration/configurable.rb +1 -1
  15. data/lib/servactory/configuration/factory.rb +30 -3
  16. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +2 -1
  17. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +150 -37
  18. data/lib/servactory/context/warehouse/inputs.rb +1 -1
  19. data/lib/servactory/context/workspace/inputs.rb +2 -2
  20. data/lib/servactory/context/workspace/internals.rb +2 -2
  21. data/lib/servactory/context/workspace/outputs.rb +2 -2
  22. data/lib/servactory/context/workspace.rb +7 -7
  23. data/lib/servactory/info/builder.rb +4 -3
  24. data/lib/servactory/inputs/dsl.rb +5 -1
  25. data/lib/servactory/inputs/input.rb +1 -2
  26. data/lib/servactory/internals/dsl.rb +7 -1
  27. data/lib/servactory/maintenance/attributes/base.rb +4 -1
  28. data/lib/servactory/maintenance/attributes/collection.rb +61 -6
  29. data/lib/servactory/maintenance/options/collection.rb +46 -15
  30. data/lib/servactory/maintenance/options/option.rb +3 -12
  31. data/lib/servactory/maintenance/options/registrar.rb +2 -12
  32. data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
  33. data/lib/servactory/outputs/dsl.rb +7 -1
  34. data/lib/servactory/result.rb +21 -8
  35. data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
  36. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
  37. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -129
  38. data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
  39. data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
  40. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +110 -45
  41. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +26 -8
  42. data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
  43. data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
  44. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
  45. data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
  46. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
  47. data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
  48. data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
  49. data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
  50. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
  51. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
  52. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
  53. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
  54. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
  55. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
  56. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
  57. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
  58. data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
  59. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +29 -8
  60. data/lib/servactory/tool_kit/dynamic_options/format.rb +2 -3
  61. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +1 -1
  62. data/lib/servactory/tool_kit/dynamic_options/max.rb +1 -1
  63. data/lib/servactory/tool_kit/dynamic_options/min.rb +1 -1
  64. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +33 -5
  65. data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
  66. data/lib/servactory/tool_kit/dynamic_options/schema.rb +34 -21
  67. data/lib/servactory/tool_kit/dynamic_options/target.rb +1 -1
  68. data/lib/servactory/utils.rb +13 -3
  69. data/lib/servactory/version.rb +1 -1
  70. data/lib/servactory.rb +3 -0
  71. metadata +10 -2
@@ -8,7 +8,7 @@ module Servactory
8
8
 
9
9
  def initialize(context)
10
10
  @class_name = context.class.name
11
- @i18n_root_key = context.config.i18n_root_key
11
+ @i18n_root_key = context.class.config.i18n_root_key
12
12
  end
13
13
 
14
14
  def translate(key, **options)
@@ -42,11 +42,11 @@ module Servactory
42
42
  end
43
43
 
44
44
  def success!
45
- raise config.success_class.new(context: self)
45
+ raise self.class.config.success_class.new(context: self)
46
46
  end
47
47
 
48
48
  def fail_input!(input_name, message:, meta: nil)
49
- raise config.input_exception_class.new(
49
+ raise self.class.config.input_exception_class.new(
50
50
  context: self,
51
51
  input_name:,
52
52
  message:,
@@ -55,7 +55,7 @@ module Servactory
55
55
  end
56
56
 
57
57
  def fail_internal!(internal_name, message:, meta: nil)
58
- raise config.internal_exception_class.new(
58
+ raise self.class.config.internal_exception_class.new(
59
59
  context: self,
60
60
  internal_name:,
61
61
  message:,
@@ -64,7 +64,7 @@ module Servactory
64
64
  end
65
65
 
66
66
  def fail_output!(output_name, message:, meta: nil)
67
- raise config.output_exception_class.new(
67
+ raise self.class.config.output_exception_class.new(
68
68
  context: self,
69
69
  output_name:,
70
70
  message:,
@@ -73,7 +73,7 @@ module Servactory
73
73
  end
74
74
 
75
75
  def fail!(type = :base, message:, meta: nil)
76
- raise config.failure_class.new(type:, message:, meta:)
76
+ raise self.class.config.failure_class.new(type:, message:, meta:)
77
77
  end
78
78
 
79
79
  def fail_result!(service_result)
@@ -118,7 +118,7 @@ module Servactory
118
118
  end
119
119
 
120
120
  def call
121
- raise config.failure_class.new(
121
+ raise self.class.config.failure_class.new(
122
122
  type: :base,
123
123
  message: servactory_service_info.translate(
124
124
  "methods.call.not_used"
@@ -26,7 +26,7 @@ module Servactory
26
26
 
27
27
  private
28
28
 
29
- # rubocop:disable Metrics/MethodLength
29
+ # rubocop:disable-next Metrics/MethodLength
30
30
  def build_all_attributes(inputs:, internals:, outputs:, stages:, config:)
31
31
  build_input_attributes_with(
32
32
  collection: inputs,
@@ -45,7 +45,6 @@ module Servactory
45
45
 
46
46
  build_action_stages_with(collection: stages)
47
47
  end
48
- # rubocop:enable Metrics/MethodLength
49
48
 
50
49
  def build_input_attributes_with(collection:, dynamic_options:)
51
50
  @inputs = build_attributes_with(
@@ -142,11 +141,13 @@ module Servactory
142
141
  end
143
142
 
144
143
  def enrich_options_with_common_fields(attribute:, options:)
144
+ types = attribute.collection_of_options.find_by(name: :types)
145
145
  must = attribute.collection_of_options.find_by(name: :must)
146
146
 
147
147
  options.merge(
148
148
  actor: attribute.actor,
149
- types: attribute.types,
149
+ type: { is: types.value, message: types.body[:message] },
150
+ types: types.value,
150
151
  must: must&.value
151
152
  )
152
153
  end
@@ -18,12 +18,16 @@ module Servactory
18
18
  private
19
19
 
20
20
  def input(name, *helpers, **options)
21
- collection_of_inputs << Input.new(
21
+ input = Input.new(
22
22
  name,
23
23
  *helpers,
24
24
  option_helpers: config.input_option_helpers,
25
25
  **options
26
26
  )
27
+
28
+ raise ArgumentError, "[#{self.name}] Input `#{name}` must have the `type` option" if input.types.empty?
29
+
30
+ collection_of_inputs << input
27
31
  end
28
32
 
29
33
  def collection_of_inputs
@@ -52,7 +52,7 @@ module Servactory
52
52
 
53
53
  attr_reader :internal_name
54
54
 
55
- # rubocop:disable Style/KeywordParametersOrder
55
+ # rubocop:disable-next Style/KeywordParametersOrder
56
56
  def initialize(
57
57
  name,
58
58
  *helpers,
@@ -64,7 +64,6 @@ module Servactory
64
64
 
65
65
  super(name, *helpers, option_helpers:, **options)
66
66
  end
67
- # rubocop:enable Style/KeywordParametersOrder
68
67
 
69
68
  def conflict_code
70
69
  @collection_of_options.defined_conflict_code
@@ -17,12 +17,18 @@ module Servactory
17
17
  private
18
18
 
19
19
  def internal(name, *helpers, **options)
20
- collection_of_internals << Internal.new(
20
+ internal = Internal.new(
21
21
  name,
22
22
  *helpers,
23
23
  option_helpers: config.internal_option_helpers,
24
24
  **options
25
25
  )
26
+
27
+ if internal.types.empty?
28
+ raise ArgumentError, "[#{self.name}] Internal attribute `#{name}` must have the `type` option"
29
+ end
30
+
31
+ collection_of_internals << internal
26
32
  end
27
33
 
28
34
  def collection_of_internals
@@ -39,8 +39,11 @@ module Servactory
39
39
  @collection_of_options = options_registrar.collection
40
40
  end
41
41
 
42
+ # @deprecated Use collection_of_options.validations_for_checks instead.
42
43
  def options_for_checks
43
- @collection_of_options.options_for_checks
44
+ warn "[DEPRECATION] Servactory::Maintenance::Attributes::Base#options_for_checks is deprecated. " \
45
+ "Use collection_of_options.validations_for_checks instead."
46
+ @collection_of_options.send(:check_options_by_key)
44
47
  end
45
48
 
46
49
  def system_name
@@ -13,6 +13,10 @@ module Servactory
13
13
  # how attributes are indexed and filtered (e.g., inputs use `internal_name`
14
14
  # for the `as:` parameter).
15
15
  #
16
+ # An attribute is declared once per name: adding an attribute whose
17
+ # `name` is already declared (e.g., redeclaring a parent's input in
18
+ # a child service) replaces the earlier declaration in place.
19
+ #
16
20
  # ## Usage
17
21
  #
18
22
  # Subclasses are used internally by Input, Internal, and Output DSL modules
@@ -32,16 +36,15 @@ module Servactory
32
36
  #
33
37
  # The collection uses memoization for frequently accessed data:
34
38
  # - `attributes_index` - cached hash for O(1) lookups by name
39
+ # - `declarations_index` - cached hash for O(1) redeclaration checks
35
40
  #
36
41
  class Collection
37
42
  extend Forwardable
38
43
 
39
44
  def_delegators :@collection,
40
- :<<,
41
45
  :filter,
42
46
  :each, :each_with_object,
43
- :map, :to_h,
44
- :merge
47
+ :map, :to_h
45
48
 
46
49
  # Initializes the collection with an optional pre-built Set.
47
50
  #
@@ -51,6 +54,50 @@ module Servactory
51
54
  @collection = collection
52
55
  end
53
56
 
57
+ # Duplicates the collection, resetting memoized caches.
58
+ #
59
+ # @param original [Collection] the collection being duplicated
60
+ # @return [void]
61
+ def initialize_dup(original)
62
+ super
63
+ @collection = original.instance_variable_get(:@collection).dup
64
+ @attributes_index = nil
65
+ @declarations_index = nil
66
+ end
67
+
68
+ # Adds an attribute to the collection, invalidating the index cache.
69
+ #
70
+ # An attribute with an already declared `name` replaces
71
+ # the earlier declaration in place, keeping its position.
72
+ #
73
+ # @param attribute [Object] the attribute to add
74
+ # @return [Collection] self
75
+ def <<(attribute)
76
+ redeclared = declarations_index[attribute.name]
77
+
78
+ if redeclared.nil?
79
+ @collection << attribute
80
+ else
81
+ @collection = Set.new(@collection) { |declared| declared.equal?(redeclared) ? attribute : declared }
82
+ end
83
+
84
+ declarations_index[attribute.name] = attribute
85
+ @attributes_index = nil
86
+ self
87
+ end
88
+
89
+ # Merges attributes from another collection, invalidating the index cache.
90
+ #
91
+ # Each attribute is added as with `<<`, so redeclarations replace
92
+ # the earlier declarations in place.
93
+ #
94
+ # @param other [Collection, Enumerable] attributes to merge
95
+ # @return [Collection] self
96
+ def merge(other)
97
+ other.each { |attribute| self << attribute }
98
+ self
99
+ end
100
+
54
101
  # Returns names of all attributes in the collection.
55
102
  #
56
103
  # @return [Array<Symbol>] list of attribute names
@@ -88,9 +135,17 @@ module Servactory
88
135
  #
89
136
  # @return [Hash{Symbol => Object}] attribute names mapped to attribute instances
90
137
  def attributes_index
91
- @attributes_index ||= each_with_object({}) do |attribute, index|
92
- index[lookup_name(attribute)] = attribute
93
- end
138
+ @attributes_index ||= to_h { |attribute| [lookup_name(attribute), attribute] }
139
+ end
140
+
141
+ # Builds and caches a hash index of declared attributes by `name`.
142
+ #
143
+ # Unlike `attributes_index`, it is keyed by the declared name
144
+ # (the external name for inputs), which identifies a redeclaration.
145
+ #
146
+ # @return [Hash{Symbol => Object}] declared names mapped to attribute instances
147
+ def declarations_index
148
+ @declarations_index ||= to_h { |attribute| [attribute.name, attribute] }
94
149
  end
95
150
 
96
151
  # Returns the name used for indexing and filtering a given attribute.
@@ -28,15 +28,13 @@ 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
39
  :each, :each_with_object,
42
40
  :map,
@@ -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,23 +75,22 @@ 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:)]
76
- end
91
+ warn "[DEPRECATION] Servactory::Maintenance::Options::Collection#options_for_checks is deprecated. " \
92
+ "Use #validations_for_checks instead."
93
+ check_options_by_key
77
94
  end
78
95
 
79
96
  # Returns options that need validation checks as an array of tuples.
@@ -115,13 +132,27 @@ module Servactory
115
132
 
116
133
  private
117
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
+
118
151
  # Builds and caches a hash index for O(1) option lookups.
119
152
  #
120
153
  # @return [Hash{Symbol => Option}] option names mapped to Option instances
121
154
  def options_index
122
- @options_index ||= each_with_object({}) do |option, index|
123
- index[option.name] = option
124
- end
155
+ @options_index ||= @collection.to_h { |option| [option.name, option] }
125
156
  end
126
157
 
127
158
  # Extracts the normalized body value from an option.
@@ -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,
@@ -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
@@ -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",