servus 0.1.3 → 0.1.5

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 (139) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/check-docs.md +1 -0
  3. data/.claude/commands/consistency-check.md +1 -0
  4. data/.claude/commands/fine-tooth-comb.md +1 -0
  5. data/.claude/commands/red-green-refactor.md +5 -0
  6. data/.claude/settings.json +15 -0
  7. data/.rubocop.yml +18 -2
  8. data/.yardopts +6 -0
  9. data/CHANGELOG.md +47 -0
  10. data/CLAUDE.md +10 -0
  11. data/IDEAS.md +5 -0
  12. data/READme.md +300 -47
  13. data/Rakefile +33 -0
  14. data/builds/servus-0.1.3.gem +0 -0
  15. data/builds/servus-0.1.4.gem +0 -0
  16. data/builds/servus-0.1.5.gem +0 -0
  17. data/docs/core/1_overview.md +77 -0
  18. data/docs/core/2_architecture.md +120 -0
  19. data/docs/core/3_service_objects.md +121 -0
  20. data/docs/current_focus.md +569 -0
  21. data/docs/features/1_schema_validation.md +119 -0
  22. data/docs/features/2_error_handling.md +121 -0
  23. data/docs/features/3_async_execution.md +81 -0
  24. data/docs/features/4_logging.md +64 -0
  25. data/docs/features/5_event_bus.md +244 -0
  26. data/docs/guides/1_common_patterns.md +90 -0
  27. data/docs/guides/2_migration_guide.md +175 -0
  28. data/docs/integration/1_configuration.md +104 -0
  29. data/docs/integration/2_testing.md +287 -0
  30. data/docs/integration/3_rails_integration.md +99 -0
  31. data/docs/yard/Servus/Base.html +1645 -0
  32. data/docs/yard/Servus/Config.html +582 -0
  33. data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
  34. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
  35. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
  36. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
  37. data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
  38. data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
  39. data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
  40. data/docs/yard/Servus/Extensions/Async.html +141 -0
  41. data/docs/yard/Servus/Extensions.html +117 -0
  42. data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
  43. data/docs/yard/Servus/Generators.html +115 -0
  44. data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
  45. data/docs/yard/Servus/Helpers.html +115 -0
  46. data/docs/yard/Servus/Railtie.html +134 -0
  47. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
  48. data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
  49. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
  50. data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
  51. data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
  52. data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
  53. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
  54. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
  55. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
  56. data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
  57. data/docs/yard/Servus/Support/Errors.html +140 -0
  58. data/docs/yard/Servus/Support/Logger.html +856 -0
  59. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
  60. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
  61. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
  62. data/docs/yard/Servus/Support/Rescuer.html +267 -0
  63. data/docs/yard/Servus/Support/Response.html +574 -0
  64. data/docs/yard/Servus/Support/Validator.html +1150 -0
  65. data/docs/yard/Servus/Support.html +119 -0
  66. data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
  67. data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
  68. data/docs/yard/Servus/Testing.html +142 -0
  69. data/docs/yard/Servus.html +343 -0
  70. data/docs/yard/_index.html +535 -0
  71. data/docs/yard/class_list.html +54 -0
  72. data/docs/yard/css/common.css +1 -0
  73. data/docs/yard/css/full_list.css +58 -0
  74. data/docs/yard/css/style.css +503 -0
  75. data/docs/yard/file.1_common_patterns.html +154 -0
  76. data/docs/yard/file.1_configuration.html +115 -0
  77. data/docs/yard/file.1_overview.html +142 -0
  78. data/docs/yard/file.1_schema_validation.html +188 -0
  79. data/docs/yard/file.2_architecture.html +157 -0
  80. data/docs/yard/file.2_error_handling.html +190 -0
  81. data/docs/yard/file.2_migration_guide.html +242 -0
  82. data/docs/yard/file.2_testing.html +227 -0
  83. data/docs/yard/file.3_async_execution.html +145 -0
  84. data/docs/yard/file.3_rails_integration.html +160 -0
  85. data/docs/yard/file.3_service_objects.html +191 -0
  86. data/docs/yard/file.4_logging.html +135 -0
  87. data/docs/yard/file.ErrorHandling.html +190 -0
  88. data/docs/yard/file.READme.html +674 -0
  89. data/docs/yard/file.architecture.html +157 -0
  90. data/docs/yard/file.async_execution.html +145 -0
  91. data/docs/yard/file.common_patterns.html +154 -0
  92. data/docs/yard/file.configuration.html +115 -0
  93. data/docs/yard/file.error_handling.html +190 -0
  94. data/docs/yard/file.logging.html +135 -0
  95. data/docs/yard/file.migration_guide.html +242 -0
  96. data/docs/yard/file.overview.html +142 -0
  97. data/docs/yard/file.rails_integration.html +160 -0
  98. data/docs/yard/file.schema_validation.html +188 -0
  99. data/docs/yard/file.service_objects.html +191 -0
  100. data/docs/yard/file.testing.html +227 -0
  101. data/docs/yard/file_list.html +119 -0
  102. data/docs/yard/frames.html +22 -0
  103. data/docs/yard/index.html +674 -0
  104. data/docs/yard/js/app.js +344 -0
  105. data/docs/yard/js/full_list.js +242 -0
  106. data/docs/yard/js/jquery.js +4 -0
  107. data/docs/yard/method_list.html +542 -0
  108. data/docs/yard/top-level-namespace.html +110 -0
  109. data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
  110. data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
  111. data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
  112. data/lib/generators/servus/service/service_generator.rb +68 -1
  113. data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
  114. data/lib/generators/servus/service/templates/result.json.erb +8 -2
  115. data/lib/generators/servus/service/templates/service.rb.erb +102 -5
  116. data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
  117. data/lib/servus/base.rb +275 -58
  118. data/lib/servus/config.rb +83 -17
  119. data/lib/servus/event_handler.rb +275 -0
  120. data/lib/servus/events/bus.rb +137 -0
  121. data/lib/servus/events/emitter.rb +162 -0
  122. data/lib/servus/events/errors.rb +10 -0
  123. data/lib/servus/extensions/async/call.rb +50 -18
  124. data/lib/servus/extensions/async/errors.rb +23 -3
  125. data/lib/servus/extensions/async/ext.rb +10 -2
  126. data/lib/servus/extensions/async/job.rb +30 -9
  127. data/lib/servus/helpers/controller_helpers.rb +73 -37
  128. data/lib/servus/railtie.rb +16 -0
  129. data/lib/servus/support/errors.rb +135 -45
  130. data/lib/servus/support/rescuer.rb +189 -36
  131. data/lib/servus/support/response.rb +49 -7
  132. data/lib/servus/support/validator.rb +147 -19
  133. data/lib/servus/testing/example_builders.rb +133 -0
  134. data/lib/servus/testing/example_extractor.rb +309 -0
  135. data/lib/servus/testing/matchers.rb +88 -0
  136. data/lib/servus/testing.rb +19 -0
  137. data/lib/servus/version.rb +1 -1
  138. data/lib/servus.rb +6 -0
  139. metadata +135 -19
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'example_extractor'
4
+
5
+ module Servus
6
+ module Testing
7
+ # Provides helper methods for extracting example values from service schemas.
8
+ #
9
+ # This module is designed to be included in test files (RSpec, Minitest, etc.)
10
+ # to provide convenient access to schema example values. It's particularly useful
11
+ # for generating test fixtures without manually maintaining separate factory files.
12
+ #
13
+ # The `servus_` prefix on method names prevents naming collisions with other
14
+ # testing libraries and makes it clear these are Servus-specific helpers.
15
+ #
16
+ # @example Include in RSpec
17
+ # # spec/spec_helper.rb
18
+ # require 'servus/testing/example_builders'
19
+ #
20
+ # RSpec.configure do |config|
21
+ # config.include Servus::Testing::ExampleBuilders
22
+ # end
23
+ #
24
+ # @example Include in Rails console (development)
25
+ # # config/environments/development.rb
26
+ # config.to_prepare do
27
+ # require 'servus/testing/example_builders'
28
+ #
29
+ # if defined?(Rails::Console)
30
+ # include Servus::Testing::ExampleBuilders
31
+ # end
32
+ # end
33
+ #
34
+ # @example Use in tests
35
+ # RSpec.describe ProcessPayment::Service do
36
+ # it 'processes payment successfully' do
37
+ # args = servus_arguments_example(ProcessPayment::Service, amount: 50.0)
38
+ # result = ProcessPayment::Service.call(**args)
39
+ #
40
+ # expect(result).to be_success
41
+ # end
42
+ # end
43
+ module ExampleBuilders
44
+ # Extracts example argument values from a service's schema.
45
+ #
46
+ # Looks for `example` or `examples` keywords in the service's arguments schema
47
+ # and returns them as a hash ready to be passed to the service's `.call` method.
48
+ #
49
+ # @param service_class [Class] The service class to extract examples from
50
+ # @param overrides [Hash] Optional values to override the schema examples
51
+ # @return [Hash<Symbol, Object>] Hash of example argument values with symbolized keys
52
+ #
53
+ # @example Basic usage
54
+ # args = servus_arguments_example(ProcessPayment::Service)
55
+ # # => { user_id: 123, amount: 100.0, currency: 'USD' }
56
+ #
57
+ # result = ProcessPayment::Service.call(**args)
58
+ #
59
+ # @example With overrides
60
+ # args = servus_arguments_example(ProcessPayment::Service, amount: 50.0, currency: 'EUR')
61
+ # # => { user_id: 123, amount: 50.0, currency: 'EUR' }
62
+ #
63
+ # @example In RSpec tests
64
+ # it 'processes different currencies' do
65
+ # %w[USD EUR GBP].each do |currency|
66
+ # result = ProcessPayment::Service.call(
67
+ # **servus_arguments_example(ProcessPayment::Service, currency: currency)
68
+ # )
69
+ # expect(result).to be_success
70
+ # end
71
+ # end
72
+ #
73
+ # @note Override keys can be strings or symbols; they'll be converted to symbols
74
+ # @note Returns empty hash if service has no arguments schema defined
75
+ def servus_arguments_example(service_class, overrides = {})
76
+ extract_example_from(service_class, :arguments, overrides)
77
+ end
78
+
79
+ # Extracts example result values from a service's schema.
80
+ #
81
+ # Looks for `example` or `examples` keywords in the service's result schema
82
+ # and returns them as a hash. Useful for validating service response structure
83
+ # and expected data shapes in tests.
84
+ #
85
+ # @param service_class [Class] The service class to extract examples from
86
+ # @param overrides [Hash] Optional values to override the schema examples
87
+ # @return [Servus::Support::Response] Response object with example result data
88
+ #
89
+ # @example Basic usage
90
+ # expected = servus_result_example(ProcessPayment::Service)
91
+ # # => Servus::Support::Response with data:
92
+ # # { transaction_id: 'txn_abc123', status: 'approved', amount_charged: 100.0 }
93
+ #
94
+ # @example Validate result structure
95
+ # result = ProcessPayment::Service.call(**servus_arguments_example(ProcessPayment::Service))
96
+ #
97
+ # expect(result.data).to match(
98
+ # hash_including(servus_result_example(ProcessPayment::Service).data)
99
+ # )
100
+ #
101
+ # @example Check result has expected keys
102
+ # result = ProcessPayment::Service.call(**args)
103
+ # expected_keys = servus_result_example(ProcessPayment::Service).data.keys
104
+ #
105
+ # expect(result.data.keys).to match_array(expected_keys)
106
+ #
107
+ # @example With overrides
108
+ # expected = servus_result_example(ProcessPayment::Service, status: 'pending').data
109
+ # # => { transaction_id: 'txn_abc123', status: 'pending', amount_charged: 100.0 }
110
+ #
111
+ # @note Override keys can be strings or symbols; they'll be converted to symbols
112
+ # @note Returns empty hash if service has no result schema defined
113
+ def servus_result_example(service_class, overrides = {})
114
+ example = extract_example_from(service_class, :result, overrides)
115
+ # Wrap in a successful Response object
116
+ Servus::Support::Response.new(true, example, nil)
117
+ end
118
+
119
+ private
120
+
121
+ # Helper method to extract and merge examples from schema
122
+ #
123
+ # @param service_class [Class] The service class to extract examples from
124
+ # @param schema_type [Symbol] The type of schema (:arguments or :result)
125
+ # @param overrides [Hash] Optional values to override the schema examples
126
+ # @return [Hash<Symbol, Object>] Hash of example values with symbolized keys
127
+ def extract_example_from(service_class, schema_type, overrides = {})
128
+ examples = ExampleExtractor.extract(service_class, schema_type)
129
+ examples.deep_merge(overrides.deep_symbolize_keys)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Testing
5
+ # Extracts example values from JSON Schema definitions for use in testing.
6
+ #
7
+ # This class understands both OpenAPI-style `example` (singular) and
8
+ # JSON Schema-style `examples` (plural, array) keywords. It can handle
9
+ # nested objects, arrays, and complex schema structures.
10
+ #
11
+ # @example Basic extraction
12
+ # schema = {
13
+ # type: 'object',
14
+ # properties: {
15
+ # name: { type: 'string', example: 'John Doe' },
16
+ # age: { type: 'integer', example: 30 }
17
+ # }
18
+ # }
19
+ #
20
+ # extractor = ExampleExtractor.new(schema)
21
+ # extractor.extract
22
+ # # => { name: 'John Doe', age: 30 }
23
+ #
24
+ # @example With service class
25
+ # examples = ExampleExtractor.extract(MyService, :arguments)
26
+ # # => { user_id: 123, amount: 100.0 }
27
+ #
28
+ # @see https://json-schema.org/understanding-json-schema/reference/annotations
29
+ # @see https://spec.openapis.org/oas/v3.1.0#schema-object
30
+ class ExampleExtractor
31
+ # Extracts example values from a service class's schema.
32
+ #
33
+ # This is a convenience class method that loads the schema via the
34
+ # Validator and extracts examples in one call.
35
+ #
36
+ # @param service_class [Class] The service class to extract examples from
37
+ # @param schema_type [Symbol] Either :arguments or :result
38
+ # @return [Hash<Symbol, Object>] Extracted example values with symbolized keys
39
+ #
40
+ # @example Extract argument examples
41
+ # ExampleExtractor.extract(ProcessPayment::Service, :arguments)
42
+ # # => { user_id: 123, amount: 100.0, currency: 'USD' }
43
+ #
44
+ # @example Extract result examples
45
+ # ExampleExtractor.extract(ProcessPayment::Service, :result)
46
+ # # => { transaction_id: 'txn_123', status: 'approved' }
47
+ def self.extract(service_class, schema_type)
48
+ schema = load_schema(service_class, schema_type)
49
+ return {} unless schema
50
+
51
+ new(schema).extract
52
+ end
53
+
54
+ # Initializes a new ExampleExtractor with a schema.
55
+ #
56
+ # The schema is deeply symbolized on initialization to normalize all keys,
57
+ # eliminating the need for double lookups throughout extraction.
58
+ #
59
+ # @param schema [Hash, nil] A JSON Schema hash with properties and examples
60
+ #
61
+ # @example
62
+ # schema = { type: 'object', properties: { name: { example: 'Test' } } }
63
+ # extractor = ExampleExtractor.new(schema)
64
+ def initialize(schema)
65
+ @schema = deep_symbolize_keys(schema)
66
+ end
67
+
68
+ # Extracts all example values from the schema.
69
+ #
70
+ # Traverses the schema structure and collects example values from:
71
+ # - Simple properties with `example` or `examples` keywords
72
+ # - Nested objects (recursively)
73
+ # - Arrays (using array-level examples or generating from item schemas)
74
+ #
75
+ # @return [Hash<Symbol, Object>] Hash of example values with symbolized keys
76
+ #
77
+ # @example Simple properties
78
+ # schema = {
79
+ # type: 'object',
80
+ # properties: {
81
+ # name: { type: 'string', example: 'John' },
82
+ # age: { type: 'integer', examples: [30, 25, 40] }
83
+ # }
84
+ # }
85
+ # extractor = ExampleExtractor.new(schema)
86
+ # extractor.extract
87
+ # # => { name: 'John', age: 30 }
88
+ #
89
+ # @example Nested objects
90
+ # schema = {
91
+ # type: 'object',
92
+ # properties: {
93
+ # user: {
94
+ # type: 'object',
95
+ # properties: {
96
+ # id: { type: 'integer', example: 123 },
97
+ # name: { type: 'string', example: 'Jane' }
98
+ # }
99
+ # }
100
+ # }
101
+ # }
102
+ # extractor = ExampleExtractor.new(schema)
103
+ # extractor.extract
104
+ # # => { user: { id: 123, name: 'Jane' } }
105
+ def extract
106
+ return {} unless @schema.is_a?(Hash)
107
+
108
+ extract_examples_from_properties(@schema)
109
+ end
110
+
111
+ private
112
+
113
+ # Extracts examples from schema properties.
114
+ #
115
+ # Iterates through the properties hash and extracts example values
116
+ # for each property that has one defined.
117
+ #
118
+ # @param schema [Hash] Schema hash containing a :properties key
119
+ # @return [Hash<Symbol, Object>] Extracted examples with symbolized keys
120
+ #
121
+ # @api private
122
+ def extract_examples_from_properties(schema)
123
+ properties = schema[:properties]
124
+ return {} unless properties
125
+
126
+ properties.each_with_object({}) do |(key, property_schema), examples|
127
+ example_value = extract_example_value(property_schema)
128
+ examples[key.to_sym] = example_value unless example_value.nil? && !explicit_nil_example?(property_schema)
129
+ end
130
+ end
131
+
132
+ # Extracts a single example value from a property schema.
133
+ #
134
+ # Handles different types of properties:
135
+ # - Simple types with `example` or `examples` keywords
136
+ # - Nested objects (recursively extracts)
137
+ # - Arrays (uses array example or generates from items)
138
+ #
139
+ # @param property_schema [Hash] The schema for a single property
140
+ # @return [Object, nil] The example value, or nil if none found
141
+ #
142
+ # @api private
143
+ def extract_example_value(property_schema)
144
+ return nil unless property_schema.is_a?(Hash)
145
+
146
+ # Check for direct example keywords first
147
+ return get_example_from_keyword(property_schema) if example_keyword?(property_schema)
148
+
149
+ # Handle nested objects
150
+ return extract_examples_from_properties(property_schema) if nested_object?(property_schema)
151
+
152
+ # Handle arrays
153
+ return extract_array_example(property_schema) if array_type?(property_schema)
154
+
155
+ nil
156
+ end
157
+
158
+ # Checks if property has an example keyword (example or examples).
159
+ #
160
+ # @param property_schema [Hash] Property schema to check
161
+ # @return [Boolean] True if example or examples keyword exists
162
+ #
163
+ # @api private
164
+ def example_keyword?(property_schema)
165
+ property_schema.key?(:example) || property_schema.key?(:examples)
166
+ end
167
+
168
+ # Checks if property explicitly sets example to nil.
169
+ #
170
+ # This is important to distinguish between "no example" and "example is nil".
171
+ #
172
+ # @param property_schema [Hash] Property schema to check
173
+ # @return [Boolean] True if example is explicitly set to nil
174
+ #
175
+ # @api private
176
+ def explicit_nil_example?(property_schema)
177
+ property_schema.key?(:example) && property_schema[:example].nil?
178
+ end
179
+
180
+ # Gets the example value from the example/examples keyword.
181
+ #
182
+ # Handles both:
183
+ # - `:example` (singular): returns the value directly
184
+ # - `:examples` (plural): returns a value from the array
185
+ #
186
+ # @param property_schema [Hash] Property schema with example keyword
187
+ # @return [Object] The example value
188
+ #
189
+ # @api private
190
+ def get_example_from_keyword(property_schema)
191
+ # Check for :example (singular) first - OpenAPI style
192
+ return property_schema[:example] if property_schema.key?(:example)
193
+
194
+ # Check for :examples (plural) - JSON Schema style
195
+ examples = property_schema[:examples]
196
+ return nil unless examples.is_a?(Array) && examples.any?
197
+
198
+ examples.sample
199
+ end
200
+
201
+ # Checks if property is a nested object type.
202
+ #
203
+ # @param property_schema [Hash] Property schema to check
204
+ # @return [Boolean] True if type is object and has properties
205
+ #
206
+ # @api private
207
+ def nested_object?(property_schema)
208
+ property_schema[:type] == 'object' && property_schema[:properties]
209
+ end
210
+
211
+ # Checks if property is an array type.
212
+ #
213
+ # @param property_schema [Hash] Property schema to check
214
+ # @return [Boolean] True if type is array
215
+ #
216
+ # @api private
217
+ def array_type?(property_schema)
218
+ property_schema[:type] == 'array'
219
+ end
220
+
221
+ # Extracts example value for an array property.
222
+ #
223
+ # Handles two strategies:
224
+ # 1. If array has direct `example` keyword, use it
225
+ # 2. Otherwise, generate array with one item using item schema examples
226
+ #
227
+ # @param property_schema [Hash] Array property schema
228
+ # @return [Array, nil] Array example or nil if can't be generated
229
+ #
230
+ # @example Array with direct example
231
+ # { type: 'array', example: [1, 2, 3] }
232
+ # # => [1, 2, 3]
233
+ #
234
+ # @example Array with item schema examples
235
+ # {
236
+ # type: 'array',
237
+ # items: {
238
+ # type: 'object',
239
+ # properties: {
240
+ # id: { type: 'integer', examples: [1, 2] },
241
+ # name: { type: 'string', examples: ['John', 'Jane'] }
242
+ # }
243
+ # }
244
+ # }
245
+ # # => [{ id: 1, name: 'John' }]
246
+ #
247
+ # @api private
248
+ def extract_array_example(property_schema)
249
+ # If array has direct example, use it
250
+ return get_example_from_keyword(property_schema) if example_keyword?(property_schema)
251
+
252
+ # Otherwise, try to generate an array with one item from the items schema
253
+ items_schema = property_schema[:items]
254
+ return nil unless items_schema
255
+
256
+ # Generate one example item from the items schema
257
+ if nested_object?(items_schema)
258
+ item_example = extract_examples_from_properties(items_schema)
259
+ return [item_example] if item_example.any?
260
+ elsif example_keyword?(items_schema)
261
+ return [get_example_from_keyword(items_schema)]
262
+ end
263
+
264
+ nil
265
+ end
266
+
267
+ # Recursively converts all hash keys to symbols.
268
+ #
269
+ # Handles nested hashes and arrays of hashes, ensuring consistent
270
+ # key types throughout the structure.
271
+ #
272
+ # @param value [Object] The value to process
273
+ # @return [Object] The value with all hash keys symbolized
274
+ #
275
+ # @api private
276
+ def deep_symbolize_keys(value)
277
+ case value
278
+ when Hash
279
+ value.each_with_object({}) do |(key, val), result|
280
+ result[key.to_sym] = deep_symbolize_keys(val)
281
+ end
282
+ when Array
283
+ value.map { |item| deep_symbolize_keys(item) }
284
+ else
285
+ value
286
+ end
287
+ end
288
+
289
+ # Loads schema from service class using Validator.
290
+ #
291
+ # Reuses the existing Validator schema loading logic which handles:
292
+ # - DSL-defined schemas
293
+ # - Constant-defined schemas
294
+ # - File-based schemas
295
+ # - Schema caching
296
+ #
297
+ # @param service_class [Class] The service class
298
+ # @param schema_type [Symbol] Either :arguments or :result
299
+ # @return [Hash, nil] The loaded schema or nil
300
+ #
301
+ # @api private
302
+ def self.load_schema(service_class, schema_type)
303
+ Servus::Support::Validator.load_schema(service_class, schema_type.to_s)
304
+ end
305
+
306
+ private_class_method :load_schema
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'rspec/expectations'
5
+
6
+ module Servus
7
+ module Testing
8
+ # RSpec matchers for testing Servus services and events.
9
+ module Matchers
10
+ end
11
+ end
12
+ end
13
+
14
+ # Matcher for asserting event emission
15
+ RSpec::Matchers.define :emit_event do |handler_class_or_symbol|
16
+ supports_block_expectations
17
+
18
+ chain :with do |payload|
19
+ @expected_payload = payload
20
+ end
21
+
22
+ match do |block|
23
+ @captured_events = []
24
+
25
+ subscription = ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, *_args, payload|
26
+ event_name = name.sub('servus.events.', '').to_sym
27
+ @captured_events << { name: event_name, payload: payload }
28
+ end
29
+
30
+ block.call
31
+
32
+ # Determine event name
33
+ @event_name = if handler_class_or_symbol.is_a?(Symbol)
34
+ handler_class_or_symbol
35
+ else
36
+ handler_class_or_symbol.event_name
37
+ end
38
+
39
+ @matching_event = @captured_events.find { |e| e[:name] == @event_name }
40
+
41
+ return false unless @matching_event
42
+ return true unless @expected_payload
43
+
44
+ RSpec::Matchers::BuiltIn::Match.new(@expected_payload).matches?(@matching_event[:payload])
45
+ ensure
46
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
47
+ end
48
+
49
+ failure_message do
50
+ if @matching_event.nil?
51
+ "expected event :#{@event_name} to be emitted, but it was not.\n" \
52
+ "Emitted: #{@captured_events.map { |e| e[:name] }}"
53
+ else
54
+ "expected event :#{@event_name} payload to match #{@expected_payload.inspect}, " \
55
+ "got: #{@matching_event[:payload].inspect}"
56
+ end
57
+ end
58
+ end
59
+
60
+ # Matcher for asserting service invocation
61
+ RSpec::Matchers.define :call_service do |service_class|
62
+ supports_block_expectations
63
+
64
+ chain :with do |args|
65
+ @expected_args = args
66
+ end
67
+
68
+ chain :async do
69
+ @expect_async = true
70
+ end
71
+
72
+ match do |block|
73
+ method_name = @expect_async ? :call_async : :call
74
+
75
+ expectation = expect(service_class).to receive(method_name)
76
+ expectation.with(@expected_args) if @expected_args
77
+
78
+ block.call
79
+
80
+ true
81
+ end
82
+
83
+ failure_message do
84
+ method = @expect_async ? 'call_async' : 'call'
85
+ "expected #{service_class} to receive #{method}"
86
+ end
87
+ end
88
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ # Testing utilities for Servus services.
5
+ #
6
+ # This module provides helpers for extracting example values from JSON schemas
7
+ # to use in tests, making it easier to create test fixtures without manually
8
+ # maintaining separate factory files.
9
+ #
10
+ # @see Servus::Testing::ExampleBuilders
11
+ # @see Servus::Testing::ExampleExtractor
12
+ # @see Servus::Testing::Matchers
13
+ module Testing
14
+ end
15
+ end
16
+
17
+ require_relative 'testing/example_extractor'
18
+ require_relative 'testing/example_builders'
19
+ require_relative 'testing/matchers'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Servus
4
- VERSION = '0.1.3'
4
+ VERSION = '0.1.5'
5
5
  end
data/lib/servus.rb CHANGED
@@ -25,6 +25,12 @@ require_relative 'servus/support/validator'
25
25
  require_relative 'servus/support/errors'
26
26
  require_relative 'servus/support/rescuer'
27
27
 
28
+ # Events
29
+ require_relative 'servus/events/errors'
30
+ require_relative 'servus/events/bus'
31
+ require_relative 'servus/events/emitter'
32
+ require_relative 'servus/event_handler'
33
+
28
34
  # Core
29
35
  require_relative 'servus/version'
30
36
  require_relative 'servus/base'