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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1321 -0
- data/Rakefile +15 -0
- data/lib/better_service/cache_service.rb +310 -0
- data/lib/better_service/concerns/instrumentation.rb +242 -0
- data/lib/better_service/concerns/serviceable/authorizable.rb +106 -0
- data/lib/better_service/concerns/serviceable/cacheable.rb +97 -0
- data/lib/better_service/concerns/serviceable/messageable.rb +30 -0
- data/lib/better_service/concerns/serviceable/presentable.rb +66 -0
- data/lib/better_service/concerns/serviceable/transactional.rb +51 -0
- data/lib/better_service/concerns/serviceable/validatable.rb +58 -0
- data/lib/better_service/concerns/serviceable/viewable.rb +49 -0
- data/lib/better_service/concerns/serviceable.rb +12 -0
- data/lib/better_service/concerns/workflowable/callbacks.rb +116 -0
- data/lib/better_service/concerns/workflowable/context.rb +108 -0
- data/lib/better_service/concerns/workflowable/step.rb +141 -0
- data/lib/better_service/concerns/workflowable.rb +12 -0
- data/lib/better_service/configuration.rb +113 -0
- data/lib/better_service/errors/better_service_error.rb +271 -0
- data/lib/better_service/errors/configuration/configuration_error.rb +21 -0
- data/lib/better_service/errors/configuration/invalid_configuration_error.rb +28 -0
- data/lib/better_service/errors/configuration/invalid_schema_error.rb +28 -0
- data/lib/better_service/errors/configuration/nil_user_error.rb +37 -0
- data/lib/better_service/errors/configuration/schema_required_error.rb +29 -0
- data/lib/better_service/errors/runtime/authorization_error.rb +38 -0
- data/lib/better_service/errors/runtime/database_error.rb +38 -0
- data/lib/better_service/errors/runtime/execution_error.rb +27 -0
- data/lib/better_service/errors/runtime/resource_not_found_error.rb +38 -0
- data/lib/better_service/errors/runtime/runtime_error.rb +22 -0
- data/lib/better_service/errors/runtime/transaction_error.rb +34 -0
- data/lib/better_service/errors/runtime/validation_error.rb +42 -0
- data/lib/better_service/errors/workflowable/configuration/duplicate_step_error.rb +27 -0
- data/lib/better_service/errors/workflowable/configuration/invalid_step_error.rb +12 -0
- data/lib/better_service/errors/workflowable/configuration/step_not_found_error.rb +29 -0
- data/lib/better_service/errors/workflowable/configuration/workflow_configuration_error.rb +24 -0
- data/lib/better_service/errors/workflowable/runtime/rollback_error.rb +46 -0
- data/lib/better_service/errors/workflowable/runtime/step_execution_error.rb +47 -0
- data/lib/better_service/errors/workflowable/runtime/workflow_execution_error.rb +40 -0
- data/lib/better_service/errors/workflowable/runtime/workflow_runtime_error.rb +25 -0
- data/lib/better_service/railtie.rb +6 -0
- data/lib/better_service/services/action_service.rb +60 -0
- data/lib/better_service/services/base.rb +249 -0
- data/lib/better_service/services/create_service.rb +60 -0
- data/lib/better_service/services/destroy_service.rb +57 -0
- data/lib/better_service/services/index_service.rb +56 -0
- data/lib/better_service/services/show_service.rb +44 -0
- data/lib/better_service/services/update_service.rb +58 -0
- data/lib/better_service/subscribers/log_subscriber.rb +131 -0
- data/lib/better_service/subscribers/stats_subscriber.rb +208 -0
- data/lib/better_service/version.rb +3 -0
- data/lib/better_service/workflows/base.rb +106 -0
- data/lib/better_service/workflows/dsl.rb +59 -0
- data/lib/better_service/workflows/execution.rb +89 -0
- data/lib/better_service/workflows/result_builder.rb +67 -0
- data/lib/better_service/workflows/rollback_support.rb +44 -0
- data/lib/better_service/workflows/transaction_support.rb +32 -0
- data/lib/better_service.rb +28 -0
- data/lib/generators/serviceable/action_generator.rb +29 -0
- data/lib/generators/serviceable/create_generator.rb +27 -0
- data/lib/generators/serviceable/destroy_generator.rb +27 -0
- data/lib/generators/serviceable/index_generator.rb +27 -0
- data/lib/generators/serviceable/scaffold_generator.rb +70 -0
- data/lib/generators/serviceable/show_generator.rb +27 -0
- data/lib/generators/serviceable/templates/action_service.rb.tt +42 -0
- data/lib/generators/serviceable/templates/create_service.rb.tt +33 -0
- data/lib/generators/serviceable/templates/destroy_service.rb.tt +40 -0
- data/lib/generators/serviceable/templates/index_service.rb.tt +54 -0
- data/lib/generators/serviceable/templates/service_test.rb.tt +23 -0
- data/lib/generators/serviceable/templates/show_service.rb.tt +37 -0
- data/lib/generators/serviceable/templates/update_service.rb.tt +50 -0
- data/lib/generators/serviceable/update_generator.rb +27 -0
- data/lib/generators/workflowable/WORKFLOW_README +27 -0
- data/lib/generators/workflowable/templates/workflow.rb.tt +72 -0
- data/lib/generators/workflowable/templates/workflow_test.rb.tt +62 -0
- data/lib/generators/workflowable/workflow_generator.rb +60 -0
- data/lib/tasks/better_service_tasks.rake +4 -0
- 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
|