better_service 1.0.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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1321 -0
  4. data/Rakefile +15 -0
  5. data/lib/better_service/cache_service.rb +310 -0
  6. data/lib/better_service/concerns/instrumentation.rb +242 -0
  7. data/lib/better_service/concerns/serviceable/authorizable.rb +106 -0
  8. data/lib/better_service/concerns/serviceable/cacheable.rb +97 -0
  9. data/lib/better_service/concerns/serviceable/messageable.rb +30 -0
  10. data/lib/better_service/concerns/serviceable/presentable.rb +66 -0
  11. data/lib/better_service/concerns/serviceable/transactional.rb +51 -0
  12. data/lib/better_service/concerns/serviceable/validatable.rb +58 -0
  13. data/lib/better_service/concerns/serviceable/viewable.rb +49 -0
  14. data/lib/better_service/concerns/serviceable.rb +12 -0
  15. data/lib/better_service/concerns/workflowable/callbacks.rb +116 -0
  16. data/lib/better_service/concerns/workflowable/context.rb +108 -0
  17. data/lib/better_service/concerns/workflowable/step.rb +141 -0
  18. data/lib/better_service/concerns/workflowable.rb +12 -0
  19. data/lib/better_service/configuration.rb +113 -0
  20. data/lib/better_service/errors/better_service_error.rb +271 -0
  21. data/lib/better_service/errors/configuration/configuration_error.rb +21 -0
  22. data/lib/better_service/errors/configuration/invalid_configuration_error.rb +28 -0
  23. data/lib/better_service/errors/configuration/invalid_schema_error.rb +28 -0
  24. data/lib/better_service/errors/configuration/nil_user_error.rb +37 -0
  25. data/lib/better_service/errors/configuration/schema_required_error.rb +29 -0
  26. data/lib/better_service/errors/runtime/authorization_error.rb +38 -0
  27. data/lib/better_service/errors/runtime/database_error.rb +38 -0
  28. data/lib/better_service/errors/runtime/execution_error.rb +27 -0
  29. data/lib/better_service/errors/runtime/resource_not_found_error.rb +38 -0
  30. data/lib/better_service/errors/runtime/runtime_error.rb +22 -0
  31. data/lib/better_service/errors/runtime/transaction_error.rb +34 -0
  32. data/lib/better_service/errors/runtime/validation_error.rb +42 -0
  33. data/lib/better_service/errors/workflowable/configuration/duplicate_step_error.rb +27 -0
  34. data/lib/better_service/errors/workflowable/configuration/invalid_step_error.rb +12 -0
  35. data/lib/better_service/errors/workflowable/configuration/step_not_found_error.rb +29 -0
  36. data/lib/better_service/errors/workflowable/configuration/workflow_configuration_error.rb +24 -0
  37. data/lib/better_service/errors/workflowable/runtime/rollback_error.rb +46 -0
  38. data/lib/better_service/errors/workflowable/runtime/step_execution_error.rb +47 -0
  39. data/lib/better_service/errors/workflowable/runtime/workflow_execution_error.rb +40 -0
  40. data/lib/better_service/errors/workflowable/runtime/workflow_runtime_error.rb +25 -0
  41. data/lib/better_service/railtie.rb +6 -0
  42. data/lib/better_service/services/action_service.rb +60 -0
  43. data/lib/better_service/services/base.rb +249 -0
  44. data/lib/better_service/services/create_service.rb +60 -0
  45. data/lib/better_service/services/destroy_service.rb +57 -0
  46. data/lib/better_service/services/index_service.rb +56 -0
  47. data/lib/better_service/services/show_service.rb +44 -0
  48. data/lib/better_service/services/update_service.rb +58 -0
  49. data/lib/better_service/subscribers/log_subscriber.rb +131 -0
  50. data/lib/better_service/subscribers/stats_subscriber.rb +208 -0
  51. data/lib/better_service/version.rb +3 -0
  52. data/lib/better_service/workflows/base.rb +106 -0
  53. data/lib/better_service/workflows/dsl.rb +59 -0
  54. data/lib/better_service/workflows/execution.rb +89 -0
  55. data/lib/better_service/workflows/result_builder.rb +67 -0
  56. data/lib/better_service/workflows/rollback_support.rb +44 -0
  57. data/lib/better_service/workflows/transaction_support.rb +32 -0
  58. data/lib/better_service.rb +28 -0
  59. data/lib/generators/serviceable/action_generator.rb +29 -0
  60. data/lib/generators/serviceable/create_generator.rb +27 -0
  61. data/lib/generators/serviceable/destroy_generator.rb +27 -0
  62. data/lib/generators/serviceable/index_generator.rb +27 -0
  63. data/lib/generators/serviceable/scaffold_generator.rb +70 -0
  64. data/lib/generators/serviceable/show_generator.rb +27 -0
  65. data/lib/generators/serviceable/templates/action_service.rb.tt +42 -0
  66. data/lib/generators/serviceable/templates/create_service.rb.tt +33 -0
  67. data/lib/generators/serviceable/templates/destroy_service.rb.tt +40 -0
  68. data/lib/generators/serviceable/templates/index_service.rb.tt +54 -0
  69. data/lib/generators/serviceable/templates/service_test.rb.tt +23 -0
  70. data/lib/generators/serviceable/templates/show_service.rb.tt +37 -0
  71. data/lib/generators/serviceable/templates/update_service.rb.tt +50 -0
  72. data/lib/generators/serviceable/update_generator.rb +27 -0
  73. data/lib/generators/workflowable/WORKFLOW_README +27 -0
  74. data/lib/generators/workflowable/templates/workflow.rb.tt +72 -0
  75. data/lib/generators/workflowable/templates/workflow_test.rb.tt +62 -0
  76. data/lib/generators/workflowable/workflow_generator.rb +60 -0
  77. data/lib/tasks/better_service_tasks.rake +4 -0
  78. metadata +180 -0
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.test_files = FileList["test/**/*_test.rb"].exclude(
9
+ "test/dummy/**/*",
10
+ "test/generators/**/*"
11
+ )
12
+ t.verbose = false
13
+ end
14
+
15
+ task default: :test
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ # CacheService - Provides cache invalidation and management for BetterService
5
+ #
6
+ # This service handles cache invalidation based on contexts defined in
7
+ # the Cacheable concern. It provides methods to invalidate cache keys
8
+ # for specific users, contexts, or globally.
9
+ #
10
+ # @example Invalidate cache for a specific context and user
11
+ # BetterService::CacheService.invalidate_for_context(current_user, "products")
12
+ #
13
+ # @example Invalidate cache globally for a context
14
+ # BetterService::CacheService.invalidate_global("sidebar")
15
+ #
16
+ # @example Invalidate all cache for a user
17
+ # BetterService::CacheService.invalidate_for_user(current_user)
18
+ class CacheService
19
+ class << self
20
+ # Invalidate cache for a specific context and user
21
+ #
22
+ # Deletes all cache keys that match the pattern for the given user and context.
23
+ # This is useful when data changes that affects a specific user's cached results.
24
+ #
25
+ # @param user [Object] The user whose cache should be invalidated
26
+ # @param context [String] The context name (e.g., "products", "sidebar")
27
+ # @param async [Boolean] Whether to perform invalidation asynchronously
28
+ # @return [Integer] Number of keys deleted (if supported by cache store)
29
+ #
30
+ # @example
31
+ # # After creating a product, invalidate products cache for user
32
+ # BetterService::CacheService.invalidate_for_context(user, "products")
33
+ def invalidate_for_context(user, context, async: false)
34
+ return 0 unless user && context && !context.to_s.strip.empty?
35
+
36
+ pattern = build_user_context_pattern(user, context)
37
+
38
+ if async
39
+ invalidate_async(pattern)
40
+ 0
41
+ else
42
+ result = delete_matched(pattern)
43
+ # Ensure we return Integer, not Array
44
+ result.is_a?(Array) ? result.size : (result || 0)
45
+ end
46
+ end
47
+
48
+ # Invalidate cache globally for a context
49
+ #
50
+ # Deletes all cache keys for the given context across all users.
51
+ # This is useful when data changes that affects everyone (e.g., global settings).
52
+ #
53
+ # @param context [String] The context name
54
+ # @param async [Boolean] Whether to perform invalidation asynchronously
55
+ # @return [Integer] Number of keys deleted (if supported by cache store)
56
+ #
57
+ # @example
58
+ # # After updating global sidebar settings
59
+ # BetterService::CacheService.invalidate_global("sidebar")
60
+ def invalidate_global(context, async: false)
61
+ return 0 unless context && !context.to_s.strip.empty?
62
+
63
+ pattern = build_global_context_pattern(context)
64
+
65
+ if async
66
+ invalidate_async(pattern)
67
+ 0
68
+ else
69
+ result = delete_matched(pattern)
70
+ # Ensure we return Integer, not Array
71
+ result.is_a?(Array) ? result.size : (result || 0)
72
+ end
73
+ end
74
+
75
+ # Invalidate all cache for a specific user
76
+ #
77
+ # Deletes all cache keys associated with the given user.
78
+ # This is useful when a user logs out or their permissions change.
79
+ #
80
+ # @param user [Object] The user whose cache should be invalidated
81
+ # @param async [Boolean] Whether to perform invalidation asynchronously
82
+ # @return [Integer] Number of keys deleted (if supported by cache store)
83
+ #
84
+ # @example
85
+ # # After user role changes
86
+ # BetterService::CacheService.invalidate_for_user(current_user)
87
+ def invalidate_for_user(user, async: false)
88
+ return 0 unless user
89
+
90
+ pattern = build_user_pattern(user)
91
+
92
+ if async
93
+ invalidate_async(pattern)
94
+ 0
95
+ else
96
+ result = delete_matched(pattern)
97
+ # Ensure we return Integer, not Array
98
+ result.is_a?(Array) ? result.size : (result || 0)
99
+ end
100
+ end
101
+
102
+ # Invalidate specific cache key
103
+ #
104
+ # Deletes a single cache key. Useful when you know the exact key.
105
+ #
106
+ # @param key [String] The cache key to delete
107
+ # @return [Boolean] true if deleted, false otherwise
108
+ #
109
+ # @example
110
+ # BetterService::CacheService.invalidate_key("products_index:user_123:abc123")
111
+ def invalidate_key(key)
112
+ return false unless key && !key.to_s.strip.empty?
113
+
114
+ Rails.cache.delete(key)
115
+ true
116
+ rescue ArgumentError
117
+ # Rails.cache.delete raises ArgumentError for invalid keys
118
+ false
119
+ end
120
+
121
+ # Clear all BetterService cache
122
+ #
123
+ # WARNING: This deletes ALL cache keys that match BetterService patterns.
124
+ # Use with caution, preferably only in development/testing.
125
+ #
126
+ # @return [Integer] Number of keys deleted (if supported by cache store)
127
+ #
128
+ # @example
129
+ # # In test setup
130
+ # BetterService::CacheService.clear_all
131
+ def clear_all
132
+ pattern = "*:user_*:*" # Match all BetterService cache keys
133
+ result = delete_matched(pattern)
134
+ # Ensure we return Integer, not Array
135
+ result.is_a?(Array) ? result.size : (result || 0)
136
+ end
137
+
138
+ # Fetch from cache with block
139
+ #
140
+ # Wrapper around Rails.cache.fetch with BetterService conventions.
141
+ # If the key exists, returns cached value. Otherwise, executes block,
142
+ # caches the result, and returns it.
143
+ #
144
+ # @param key [String] The cache key
145
+ # @param options [Hash] Options passed to Rails.cache.fetch
146
+ # @option options [Integer] :expires_in TTL in seconds
147
+ # @option options [Boolean] :force Force cache refresh
148
+ # @return [Object] The cached or computed value
149
+ #
150
+ # @example
151
+ # result = BetterService::CacheService.fetch("my_key", expires_in: 1.hour) do
152
+ # expensive_computation
153
+ # end
154
+ def fetch(key, options = {}, &block)
155
+ Rails.cache.fetch(key, options, &block)
156
+ end
157
+
158
+ # Check if a key exists in cache
159
+ #
160
+ # @param key [String] The cache key to check
161
+ # @return [Boolean] true if key exists, false otherwise
162
+ def exist?(key)
163
+ return false unless key && !key.to_s.strip.empty?
164
+
165
+ Rails.cache.exist?(key)
166
+ end
167
+
168
+ # Get cache statistics
169
+ #
170
+ # Returns information about cache store and BetterService cache usage.
171
+ # Note: Detailed stats only available with certain cache stores (Redis).
172
+ #
173
+ # @return [Hash] Cache statistics
174
+ def stats
175
+ {
176
+ cache_store: Rails.cache.class.name,
177
+ supports_pattern_deletion: supports_delete_matched?,
178
+ supports_async: defined?(ActiveJob) ? true : false
179
+ }
180
+ end
181
+
182
+ private
183
+
184
+ # Build cache key pattern for user + context
185
+ #
186
+ # @param user [Object] User object with id
187
+ # @param context [String] Context name
188
+ # @return [String] Pattern like "*:user_123:*:products"
189
+ def build_user_context_pattern(user, context)
190
+ user_id = user.respond_to?(:id) ? user.id : user
191
+ "*:user_#{user_id}:*:#{context}"
192
+ end
193
+
194
+ # Build cache key pattern for global context
195
+ #
196
+ # @param context [String] Context name
197
+ # @return [String] Pattern like "*:#{context}"
198
+ def build_global_context_pattern(context)
199
+ "*:#{context}"
200
+ end
201
+
202
+ # Build cache key pattern for user (all contexts)
203
+ #
204
+ # @param user [Object] User object with id
205
+ # @return [String] Pattern like "*:user_123:*"
206
+ def build_user_pattern(user)
207
+ user_id = user.respond_to?(:id) ? user.id : user
208
+ "*:user_#{user_id}:*"
209
+ end
210
+
211
+ # Delete cache keys matching pattern
212
+ #
213
+ # Uses Rails.cache.delete_matched if supported by cache store.
214
+ # Falls back to no-op if not supported (e.g., MemoryStore).
215
+ #
216
+ # @param pattern [String] Pattern with wildcards
217
+ # @return [Integer] Number of keys deleted (0 if not supported)
218
+ def delete_matched(pattern)
219
+ if supports_delete_matched?
220
+ # Convert wildcard pattern to regex for Rails.cache.delete_matched
221
+ # Pattern like "*:user_123:*:products" becomes /.*:user_123:.*:products/
222
+ regex_pattern = convert_pattern_to_regex(pattern)
223
+ count = Rails.cache.delete_matched(regex_pattern)
224
+ log_invalidation(pattern, count)
225
+ count || 0
226
+ else
227
+ log_warning("Cache store #{Rails.cache.class.name} does not support pattern deletion")
228
+ 0
229
+ end
230
+ end
231
+
232
+ # Convert wildcard pattern to Regexp
233
+ #
234
+ # @param pattern [String] Pattern with * wildcards
235
+ # @return [Regexp] Regexp for matching cache keys
236
+ def convert_pattern_to_regex(pattern)
237
+ return // unless pattern
238
+
239
+ # Escape special regex characters except *
240
+ escaped = Regexp.escape(pattern.to_s)
241
+ # Replace escaped \* with .* for regex matching
242
+ regex_string = escaped.gsub('\*', '.*')
243
+ Regexp.new(regex_string)
244
+ end
245
+
246
+ # Invalidate cache asynchronously using ActiveJob
247
+ #
248
+ # @param pattern [String] Pattern to invalidate
249
+ # @return [void]
250
+ def invalidate_async(pattern)
251
+ if defined?(ActiveJob)
252
+ CacheInvalidationJob.perform_later(pattern)
253
+ log_invalidation(pattern, "async")
254
+ else
255
+ # Fallback to synchronous if ActiveJob not available
256
+ delete_matched(pattern)
257
+ end
258
+ end
259
+
260
+ # Check if cache store supports delete_matched
261
+ #
262
+ # @return [Boolean]
263
+ def supports_delete_matched?
264
+ Rails.cache.respond_to?(:delete_matched)
265
+ end
266
+
267
+ # Log cache invalidation
268
+ #
269
+ # @param pattern [String] Pattern invalidated
270
+ # @param count [Integer, String] Number of keys or "async"
271
+ # @return [void]
272
+ def log_invalidation(pattern, count)
273
+ return unless defined?(Rails) && Rails.logger
274
+
275
+ if count == "async"
276
+ Rails.logger.info "[BetterService::CacheService] Async invalidation queued: #{pattern}"
277
+ else
278
+ Rails.logger.info "[BetterService::CacheService] Invalidated #{count} keys matching: #{pattern}"
279
+ end
280
+ end
281
+
282
+ # Log warning
283
+ #
284
+ # @param message [String] Warning message
285
+ # @return [void]
286
+ def log_warning(message)
287
+ return unless defined?(Rails) && Rails.logger
288
+
289
+ Rails.logger.warn "[BetterService::CacheService] #{message}"
290
+ end
291
+ end
292
+
293
+ # ActiveJob for async cache invalidation
294
+ #
295
+ # This job is only defined if ActiveJob is available.
296
+ # It allows cache invalidation to happen in the background.
297
+ if defined?(ActiveJob)
298
+ class CacheInvalidationJob < ActiveJob::Base
299
+ queue_as :default
300
+
301
+ # Perform cache invalidation
302
+ #
303
+ # @param pattern [String] Cache key pattern to invalidate
304
+ def perform(pattern)
305
+ BetterService::CacheService.send(:delete_matched, pattern)
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ # Instrumentation - Provides automatic event publishing for service execution
5
+ #
6
+ # This concern automatically publishes ActiveSupport::Notifications events
7
+ # during the service lifecycle, enabling monitoring, metrics, and observability.
8
+ #
9
+ # Events published:
10
+ # - service.started - When service execution begins
11
+ # - service.completed - When service completes successfully
12
+ # - service.failed - When service raises an exception
13
+ # - cache.hit - When cache lookup returns cached data
14
+ # - cache.miss - When cache lookup requires fresh computation
15
+ #
16
+ # @example Subscribe to all service events
17
+ # ActiveSupport::Notifications.subscribe("service.completed") do |name, start, finish, id, payload|
18
+ # puts "#{payload[:service_name]} took #{payload[:duration]}ms"
19
+ # end
20
+ #
21
+ # @example Subscribe to specific service
22
+ # ActiveSupport::Notifications.subscribe("service.completed") do |name, start, finish, id, payload|
23
+ # if payload[:service_name] == "ProductsIndexService"
24
+ # DataDog.histogram("products.index.duration", payload[:duration])
25
+ # end
26
+ # end
27
+ module Concerns
28
+ module Instrumentation
29
+ extend ActiveSupport::Concern
30
+
31
+ # Hook into prepend to wrap call method
32
+ #
33
+ # This is called when the concern is prepended to a class.
34
+ def self.prepended(base)
35
+ # Always wrap call method
36
+ base.class_eval do
37
+ # Save original call method
38
+ alias_method :call_without_instrumentation, :call
39
+
40
+ # Define new call method with instrumentation
41
+ define_method(:call) do
42
+ return call_without_instrumentation unless instrumentation_enabled?
43
+
44
+ service_name = self.class.name
45
+ user_id = extract_user_id_from_instance
46
+
47
+ # Publish service.started event
48
+ payload = build_start_payload(service_name, user_id)
49
+ ActiveSupport::Notifications.instrument("service.started", payload)
50
+
51
+ # Execute the service
52
+ start_time = Time.current
53
+
54
+ begin
55
+ result = call_without_instrumentation
56
+ duration = ((Time.current - start_time) * 1000).round(2) # milliseconds
57
+
58
+ # Publish service.completed event
59
+ completion_payload = build_completion_payload(
60
+ service_name, user_id, result, duration
61
+ )
62
+ ActiveSupport::Notifications.instrument("service.completed", completion_payload)
63
+
64
+ result
65
+ rescue => error
66
+ duration = ((Time.current - start_time) * 1000).round(2)
67
+
68
+ # Extract original error if wrapped in ExecutionError
69
+ original_error = error.respond_to?(:original_error) && error.original_error ? error.original_error : error
70
+
71
+ # Publish service.failed event
72
+ failure_payload = build_failure_payload(
73
+ service_name, user_id, original_error, duration
74
+ )
75
+ ActiveSupport::Notifications.instrument("service.failed", failure_payload)
76
+
77
+ # Re-raise the error (don't swallow it)
78
+ raise
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ # Check if instrumentation is enabled for this service
85
+ #
86
+ # @return [Boolean]
87
+ def instrumentation_enabled?
88
+ return false unless BetterService.configuration.instrumentation_enabled
89
+
90
+ excluded = BetterService.configuration.instrumentation_excluded_services
91
+ full_name = self.class.name
92
+
93
+ # Check exact match or if excluded name matches the end of full name
94
+ !excluded.any? { |excluded_name| full_name == excluded_name || full_name.end_with?("::#{excluded_name}") }
95
+ end
96
+
97
+ # Extract user ID from service instance
98
+ #
99
+ # @return [Integer, String, nil]
100
+ def extract_user_id_from_instance
101
+ return nil unless respond_to?(:user, true)
102
+
103
+ user = send(:user)
104
+ return nil unless user
105
+
106
+ user.respond_to?(:id) ? user.id : user
107
+ end
108
+
109
+ # Build payload for service.started event
110
+ #
111
+ # @param service_name [String] Name of service class
112
+ # @param user_id [Integer, String, nil] User ID
113
+ # @return [Hash] Event payload
114
+ def build_start_payload(service_name, user_id)
115
+ payload = {
116
+ service_name: service_name,
117
+ user_id: user_id,
118
+ timestamp: Time.current.iso8601
119
+ }
120
+
121
+ # Include params if configured and available
122
+ if BetterService.configuration.instrumentation_include_args && respond_to?(:params, true)
123
+ payload[:params] = send(:params)
124
+ end
125
+
126
+ payload
127
+ end
128
+
129
+ # Build payload for service.completed event
130
+ #
131
+ # @param service_name [String] Name of service class
132
+ # @param user_id [Integer, String, nil] User ID
133
+ # @param result [Object] Service result
134
+ # @param duration [Float] Execution duration in milliseconds
135
+ # @return [Hash] Event payload
136
+ def build_completion_payload(service_name, user_id, result, duration)
137
+ payload = {
138
+ service_name: service_name,
139
+ user_id: user_id,
140
+ duration: duration,
141
+ timestamp: Time.current.iso8601,
142
+ success: true
143
+ }
144
+
145
+ # Include params if configured and available
146
+ if BetterService.configuration.instrumentation_include_args && respond_to?(:params, true)
147
+ payload[:params] = send(:params)
148
+ end
149
+
150
+ # Include result if configured
151
+ if BetterService.configuration.instrumentation_include_result
152
+ payload[:result] = result
153
+ end
154
+
155
+ # Include cache metadata if available
156
+ if result.is_a?(Hash)
157
+ if result.key?(:cache_hit)
158
+ payload[:cache_hit] = result[:cache_hit]
159
+ end
160
+ if result.key?(:cache_key)
161
+ payload[:cache_key] = result[:cache_key]
162
+ end
163
+ end
164
+
165
+ payload
166
+ end
167
+
168
+ # Build payload for service.failed event
169
+ #
170
+ # @param service_name [String] Name of service class
171
+ # @param user_id [Integer, String, nil] User ID
172
+ # @param error [Exception] The error that was raised
173
+ # @param duration [Float] Execution duration in milliseconds
174
+ # @return [Hash] Event payload
175
+ def build_failure_payload(service_name, user_id, error, duration)
176
+ payload = {
177
+ service_name: service_name,
178
+ user_id: user_id,
179
+ duration: duration,
180
+ timestamp: Time.current.iso8601,
181
+ success: false,
182
+ error_class: error.class.name,
183
+ error_message: error.message
184
+ }
185
+
186
+ # Include params if configured and available
187
+ if BetterService.configuration.instrumentation_include_args && respond_to?(:params, true)
188
+ payload[:params] = send(:params)
189
+ end
190
+
191
+ # Include backtrace (first 5 lines) for debugging
192
+ if error.backtrace
193
+ payload[:error_backtrace] = error.backtrace.first(5)
194
+ end
195
+
196
+ payload
197
+ end
198
+
199
+ # Publish cache hit event
200
+ #
201
+ # Called from Cacheable concern when cache lookup succeeds.
202
+ #
203
+ # @param cache_key [String] The cache key that was hit
204
+ # @param context [String] Cache context (e.g., "products")
205
+ # @return [void]
206
+ def publish_cache_hit(cache_key, context = nil)
207
+ return unless instrumentation_enabled?
208
+
209
+ payload = {
210
+ service_name: self.class.name,
211
+ event_type: "cache_hit",
212
+ cache_key: cache_key,
213
+ context: context,
214
+ timestamp: Time.current.iso8601
215
+ }
216
+
217
+ ActiveSupport::Notifications.instrument("cache.hit", payload)
218
+ end
219
+
220
+ # Publish cache miss event
221
+ #
222
+ # Called from Cacheable concern when cache lookup fails.
223
+ #
224
+ # @param cache_key [String] The cache key that missed
225
+ # @param context [String] Cache context (e.g., "products")
226
+ # @return [void]
227
+ def publish_cache_miss(cache_key, context = nil)
228
+ return unless instrumentation_enabled?
229
+
230
+ payload = {
231
+ service_name: self.class.name,
232
+ event_type: "cache_miss",
233
+ cache_key: cache_key,
234
+ context: context,
235
+ timestamp: Time.current.iso8601
236
+ }
237
+
238
+ ActiveSupport::Notifications.instrument("cache.miss", payload)
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ module Concerns
5
+ module Serviceable
6
+ # Authorizable adds authorization support to services.
7
+ #
8
+ # Use the `authorize_with` DSL to define authorization logic that runs
9
+ # BEFORE the search phase (fail fast principle).
10
+ #
11
+ # The authorization block has access to:
12
+ # - user: The current user object
13
+ # - params: The validated parameters
14
+ #
15
+ # If authorization fails (block returns false/nil), the service stops
16
+ # immediately and raises AuthorizationError with code :unauthorized.
17
+ #
18
+ # Example:
19
+ # class Product::UpdateService < BetterService::UpdateService
20
+ # authorize_with do
21
+ # user.admin? || product_belongs_to_user?
22
+ # end
23
+ #
24
+ # def product_belongs_to_user?
25
+ # Product.find(params[:id]).user_id == user.id
26
+ # end
27
+ # end
28
+ #
29
+ # Works with any authorization library (Pundit, CanCanCan, custom):
30
+ #
31
+ # # Pundit style
32
+ # authorize_with do
33
+ # ProductPolicy.new(user, resource).update?
34
+ # end
35
+ #
36
+ # # CanCanCan style
37
+ # authorize_with do
38
+ # Ability.new(user).can?(:update, :product)
39
+ # end
40
+ #
41
+ # # Custom logic
42
+ # authorize_with do
43
+ # user.has_role?(:editor) && params[:status] != 'published'
44
+ # end
45
+ module Authorizable
46
+ extend ActiveSupport::Concern
47
+
48
+ included do
49
+ class_attribute :_authorize_block, default: nil
50
+ end
51
+
52
+ class_methods do
53
+ # Define authorization logic that runs before search phase.
54
+ #
55
+ # @yield Block that returns true/false for authorization check
56
+ # @return [void]
57
+ #
58
+ # @example Simple role check
59
+ # authorize_with do
60
+ # user.admin?
61
+ # end
62
+ #
63
+ # @example Resource ownership check
64
+ # authorize_with do
65
+ # resource = Product.find(params[:id])
66
+ # resource.user_id == user.id
67
+ # end
68
+ #
69
+ # @example With Pundit
70
+ # authorize_with do
71
+ # ProductPolicy.new(user, Product.find(params[:id])).update?
72
+ # end
73
+ def authorize_with(&block)
74
+ self._authorize_block = block
75
+ end
76
+ end
77
+
78
+ # Execute authorization check if defined.
79
+ #
80
+ # Runs the authorization block defined with `authorize_with`.
81
+ # Has access to user and params.
82
+ #
83
+ # @raise [Errors::Runtime::AuthorizationError] If authorization fails
84
+ # @return [void]
85
+ def authorize!
86
+ return unless self.class._authorize_block
87
+
88
+ authorized = instance_exec(&self.class._authorize_block)
89
+
90
+ return if authorized
91
+
92
+ # Raise AuthorizationError instead of returning hash
93
+ raise Errors::Runtime::AuthorizationError.new(
94
+ "Not authorized to perform this action",
95
+ code: ErrorCodes::UNAUTHORIZED,
96
+ context: {
97
+ service: self.class.name,
98
+ user: user&.id || "nil",
99
+ params: @params
100
+ }
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end