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.
- checksums.yaml +4 -4
- data/.claude/commands/check-docs.md +1 -0
- data/.claude/commands/consistency-check.md +1 -0
- data/.claude/commands/fine-tooth-comb.md +1 -0
- data/.claude/commands/red-green-refactor.md +5 -0
- data/.claude/settings.json +15 -0
- data/.rubocop.yml +18 -2
- data/.yardopts +6 -0
- data/CHANGELOG.md +47 -0
- data/CLAUDE.md +10 -0
- data/IDEAS.md +5 -0
- data/READme.md +300 -47
- data/Rakefile +33 -0
- data/builds/servus-0.1.3.gem +0 -0
- data/builds/servus-0.1.4.gem +0 -0
- data/builds/servus-0.1.5.gem +0 -0
- data/docs/core/1_overview.md +77 -0
- data/docs/core/2_architecture.md +120 -0
- data/docs/core/3_service_objects.md +121 -0
- data/docs/current_focus.md +569 -0
- data/docs/features/1_schema_validation.md +119 -0
- data/docs/features/2_error_handling.md +121 -0
- data/docs/features/3_async_execution.md +81 -0
- data/docs/features/4_logging.md +64 -0
- data/docs/features/5_event_bus.md +244 -0
- data/docs/guides/1_common_patterns.md +90 -0
- data/docs/guides/2_migration_guide.md +175 -0
- data/docs/integration/1_configuration.md +104 -0
- data/docs/integration/2_testing.md +287 -0
- data/docs/integration/3_rails_integration.md +99 -0
- data/docs/yard/Servus/Base.html +1645 -0
- data/docs/yard/Servus/Config.html +582 -0
- data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
- data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
- data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
- data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
- data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
- data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
- data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
- data/docs/yard/Servus/Extensions/Async.html +141 -0
- data/docs/yard/Servus/Extensions.html +117 -0
- data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
- data/docs/yard/Servus/Generators.html +115 -0
- data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
- data/docs/yard/Servus/Helpers.html +115 -0
- data/docs/yard/Servus/Railtie.html +134 -0
- data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
- data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
- data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
- data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
- data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
- data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
- data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
- data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
- data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
- data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
- data/docs/yard/Servus/Support/Errors.html +140 -0
- data/docs/yard/Servus/Support/Logger.html +856 -0
- data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
- data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
- data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
- data/docs/yard/Servus/Support/Rescuer.html +267 -0
- data/docs/yard/Servus/Support/Response.html +574 -0
- data/docs/yard/Servus/Support/Validator.html +1150 -0
- data/docs/yard/Servus/Support.html +119 -0
- data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
- data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
- data/docs/yard/Servus/Testing.html +142 -0
- data/docs/yard/Servus.html +343 -0
- data/docs/yard/_index.html +535 -0
- data/docs/yard/class_list.html +54 -0
- data/docs/yard/css/common.css +1 -0
- data/docs/yard/css/full_list.css +58 -0
- data/docs/yard/css/style.css +503 -0
- data/docs/yard/file.1_common_patterns.html +154 -0
- data/docs/yard/file.1_configuration.html +115 -0
- data/docs/yard/file.1_overview.html +142 -0
- data/docs/yard/file.1_schema_validation.html +188 -0
- data/docs/yard/file.2_architecture.html +157 -0
- data/docs/yard/file.2_error_handling.html +190 -0
- data/docs/yard/file.2_migration_guide.html +242 -0
- data/docs/yard/file.2_testing.html +227 -0
- data/docs/yard/file.3_async_execution.html +145 -0
- data/docs/yard/file.3_rails_integration.html +160 -0
- data/docs/yard/file.3_service_objects.html +191 -0
- data/docs/yard/file.4_logging.html +135 -0
- data/docs/yard/file.ErrorHandling.html +190 -0
- data/docs/yard/file.READme.html +674 -0
- data/docs/yard/file.architecture.html +157 -0
- data/docs/yard/file.async_execution.html +145 -0
- data/docs/yard/file.common_patterns.html +154 -0
- data/docs/yard/file.configuration.html +115 -0
- data/docs/yard/file.error_handling.html +190 -0
- data/docs/yard/file.logging.html +135 -0
- data/docs/yard/file.migration_guide.html +242 -0
- data/docs/yard/file.overview.html +142 -0
- data/docs/yard/file.rails_integration.html +160 -0
- data/docs/yard/file.schema_validation.html +188 -0
- data/docs/yard/file.service_objects.html +191 -0
- data/docs/yard/file.testing.html +227 -0
- data/docs/yard/file_list.html +119 -0
- data/docs/yard/frames.html +22 -0
- data/docs/yard/index.html +674 -0
- data/docs/yard/js/app.js +344 -0
- data/docs/yard/js/full_list.js +242 -0
- data/docs/yard/js/jquery.js +4 -0
- data/docs/yard/method_list.html +542 -0
- data/docs/yard/top-level-namespace.html +110 -0
- data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
- data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
- data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
- data/lib/generators/servus/service/service_generator.rb +68 -1
- data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
- data/lib/generators/servus/service/templates/result.json.erb +8 -2
- data/lib/generators/servus/service/templates/service.rb.erb +102 -5
- data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
- data/lib/servus/base.rb +275 -58
- data/lib/servus/config.rb +83 -17
- data/lib/servus/event_handler.rb +275 -0
- data/lib/servus/events/bus.rb +137 -0
- data/lib/servus/events/emitter.rb +162 -0
- data/lib/servus/events/errors.rb +10 -0
- data/lib/servus/extensions/async/call.rb +50 -18
- data/lib/servus/extensions/async/errors.rb +23 -3
- data/lib/servus/extensions/async/ext.rb +10 -2
- data/lib/servus/extensions/async/job.rb +30 -9
- data/lib/servus/helpers/controller_helpers.rb +73 -37
- data/lib/servus/railtie.rb +16 -0
- data/lib/servus/support/errors.rb +135 -45
- data/lib/servus/support/rescuer.rb +189 -36
- data/lib/servus/support/response.rb +49 -7
- data/lib/servus/support/validator.rb +147 -19
- data/lib/servus/testing/example_builders.rb +133 -0
- data/lib/servus/testing/example_extractor.rb +309 -0
- data/lib/servus/testing/matchers.rb +88 -0
- data/lib/servus/testing.rb +19 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +6 -0
- metadata +135 -19
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# @title Guides / 2. Migration Guide
|
|
2
|
+
|
|
3
|
+
# Migration Guide
|
|
4
|
+
|
|
5
|
+
Strategies for adopting Servus in existing Rails applications.
|
|
6
|
+
|
|
7
|
+
## Incremental Adoption
|
|
8
|
+
|
|
9
|
+
Servus coexists with existing code - no need to rewrite your entire application. Start with one complex use case, validate the pattern works for your team, then expand gradually.
|
|
10
|
+
|
|
11
|
+
## Extracting from Fat Controllers
|
|
12
|
+
|
|
13
|
+
Identify controller actions with complex business logic and extract to services:
|
|
14
|
+
|
|
15
|
+
**Before**:
|
|
16
|
+
```ruby
|
|
17
|
+
class OrdersController < ApplicationController
|
|
18
|
+
def create
|
|
19
|
+
# 50 lines of business logic
|
|
20
|
+
# Multiple model operations
|
|
21
|
+
# External API calls
|
|
22
|
+
# Email sending
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**After**:
|
|
28
|
+
```ruby
|
|
29
|
+
class OrdersController < ApplicationController
|
|
30
|
+
def create
|
|
31
|
+
result = Orders::Create::Service.call(order_params)
|
|
32
|
+
if result.success?
|
|
33
|
+
render json: { order: result.data[:order] }, status: :created
|
|
34
|
+
else
|
|
35
|
+
render json: { error: result.error.api_error }, status: :unprocessable_entity
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Or use the helper
|
|
41
|
+
class OrdersController < ApplicationController
|
|
42
|
+
include Servus::Helpers::ControllerHelpers
|
|
43
|
+
|
|
44
|
+
def create
|
|
45
|
+
run_service(Orders::Create::Service, order_params) do |result|
|
|
46
|
+
render json: { order: result.data[:order] }, status: :created
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Extracting from Fat Models
|
|
53
|
+
|
|
54
|
+
Move orchestration logic from models to services. Keep data-related methods in models:
|
|
55
|
+
|
|
56
|
+
**Before**:
|
|
57
|
+
```ruby
|
|
58
|
+
class Order < ApplicationRecord
|
|
59
|
+
def complete_purchase
|
|
60
|
+
charge_payment
|
|
61
|
+
update_inventory
|
|
62
|
+
send_confirmation_email
|
|
63
|
+
create_invoice
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**After**:
|
|
69
|
+
```ruby
|
|
70
|
+
class Order < ApplicationRecord
|
|
71
|
+
# Model focuses on data
|
|
72
|
+
validates :total, presence: true
|
|
73
|
+
belongs_to :user
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Orders::CompletePurchase::Service < Servus::Base
|
|
77
|
+
# Service handles orchestration
|
|
78
|
+
def initialize(order_id:)
|
|
79
|
+
@order_id = order_id
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def call
|
|
83
|
+
order = Order.find(@order_id)
|
|
84
|
+
Payments::Charge::Service.call(order_id: order.id)
|
|
85
|
+
Inventory::Update::Service.call(order_id: order.id)
|
|
86
|
+
Mailers::SendConfirmation::Service.call(order_id: order.id)
|
|
87
|
+
success(order: order)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Replacing Callbacks
|
|
93
|
+
|
|
94
|
+
Extract callback logic to explicit service calls:
|
|
95
|
+
|
|
96
|
+
**Before**:
|
|
97
|
+
```ruby
|
|
98
|
+
class User < ApplicationRecord
|
|
99
|
+
after_create :send_welcome_email
|
|
100
|
+
after_create :create_default_account
|
|
101
|
+
after_update :notify_changes, if: :email_changed?
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**After**:
|
|
106
|
+
```ruby
|
|
107
|
+
class User < ApplicationRecord
|
|
108
|
+
# Minimal or no callbacks
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class Users::Create::Service < Servus::Base
|
|
112
|
+
def call
|
|
113
|
+
user = User.create!(params)
|
|
114
|
+
send_welcome_email(user)
|
|
115
|
+
create_default_account(user)
|
|
116
|
+
success(user: user)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Migrating Background Jobs
|
|
122
|
+
|
|
123
|
+
Extract job logic to services, call via `.call_async`:
|
|
124
|
+
|
|
125
|
+
**Before**:
|
|
126
|
+
```ruby
|
|
127
|
+
class ProcessOrderJob < ApplicationJob
|
|
128
|
+
def perform(order_id)
|
|
129
|
+
# 50 lines of business logic
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
ProcessOrderJob.perform_later(order.id)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**After**:
|
|
137
|
+
```ruby
|
|
138
|
+
class Orders::Process::Service < Servus::Base
|
|
139
|
+
def initialize(order_id:)
|
|
140
|
+
@order_id = order_id
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def call
|
|
144
|
+
# Business logic
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
Orders::Process::Service.call_async(order_id: order.id)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Now the service can be called synchronously (from console, tests) or asynchronously (from controllers, jobs).
|
|
152
|
+
|
|
153
|
+
## Testing During Migration
|
|
154
|
+
|
|
155
|
+
Keep existing tests working while adding service tests:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Keep existing controller test
|
|
159
|
+
describe OrdersController do
|
|
160
|
+
it "creates order" do
|
|
161
|
+
post :create, params: params
|
|
162
|
+
expect(response).to be_successful
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Add service test
|
|
167
|
+
describe Orders::Create::Service do
|
|
168
|
+
it "creates order" do
|
|
169
|
+
result = described_class.call(params)
|
|
170
|
+
expect(result.success?).to be true
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Remove legacy tests after service tests prove comprehensive.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# @title Integration / 1. Configuration
|
|
2
|
+
|
|
3
|
+
# Configuration
|
|
4
|
+
|
|
5
|
+
Servus works without configuration. Optional settings exist for customizing directories and event validation.
|
|
6
|
+
|
|
7
|
+
## Directory Configuration
|
|
8
|
+
|
|
9
|
+
Configure where Servus looks for schemas, services, and event handlers:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# config/initializers/servus.rb
|
|
13
|
+
Servus.configure do |config|
|
|
14
|
+
# Default: 'app/schemas'
|
|
15
|
+
config.schemas_dir = 'app/schemas'
|
|
16
|
+
|
|
17
|
+
# Default: 'app/services'
|
|
18
|
+
config.services_dir = 'app/services'
|
|
19
|
+
|
|
20
|
+
# Default: 'app/events'
|
|
21
|
+
config.events_dir = 'app/events'
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
These affect legacy file-based schemas and handler auto-loading. Schemas defined via the `schema` DSL method do not use files.
|
|
26
|
+
|
|
27
|
+
## Schema Cache
|
|
28
|
+
|
|
29
|
+
Schemas are cached after first load for performance. Clear the cache during development when schemas change:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
Servus::Support::Validator.clear_cache!
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
In production, schemas are deployed with code - no need to clear cache.
|
|
36
|
+
|
|
37
|
+
## Log Level
|
|
38
|
+
|
|
39
|
+
Servus uses `Rails.logger` (or stdout in non-Rails apps). Control logging via Rails configuration:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/environments/production.rb
|
|
43
|
+
config.log_level = :info # Hides DEBUG argument logs
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## ActiveJob Configuration
|
|
47
|
+
|
|
48
|
+
Async execution uses ActiveJob. Configure your adapter:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# config/application.rb
|
|
52
|
+
config.active_job.queue_adapter = :sidekiq
|
|
53
|
+
config.active_job.default_queue_name = :default
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Servus respects ActiveJob queue configuration - no Servus-specific setup needed.
|
|
57
|
+
|
|
58
|
+
## Event Bus Configuration
|
|
59
|
+
|
|
60
|
+
### Strict Event Validation
|
|
61
|
+
|
|
62
|
+
Enable strict validation to catch handlers subscribing to events that aren't emitted by any service:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# config/initializers/servus.rb
|
|
66
|
+
Servus.configure do |config|
|
|
67
|
+
# Default: true
|
|
68
|
+
config.strict_event_validation = true
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
When enabled, you can validate handlers at boot or in CI:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# In a rake task or initializer
|
|
76
|
+
Servus::EventHandler.validate_all_handlers!
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This raises `Servus::Events::OrphanedHandlerError` if any handler subscribes to a non-existent event.
|
|
80
|
+
|
|
81
|
+
### Handler Auto-Loading
|
|
82
|
+
|
|
83
|
+
In Rails, handlers in `app/events/` are automatically loaded. The Railtie:
|
|
84
|
+
- Clears the event bus on reload in development
|
|
85
|
+
- Eager-loads all `*_handler.rb` files from `config.events_dir`
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
app/events/
|
|
89
|
+
├── user_created_handler.rb
|
|
90
|
+
├── payment_processed_handler.rb
|
|
91
|
+
└── order_completed_handler.rb
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Event Instrumentation
|
|
95
|
+
|
|
96
|
+
Events are instrumented via ActiveSupport::Notifications with the prefix `servus.events.`:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Subscribe to all Servus events
|
|
100
|
+
ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, *args|
|
|
101
|
+
event_name = name.sub('servus.events.', '')
|
|
102
|
+
Rails.logger.info "Event: #{event_name}"
|
|
103
|
+
end
|
|
104
|
+
```
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# @title Integration / 2. Testing
|
|
2
|
+
|
|
3
|
+
# Testing
|
|
4
|
+
|
|
5
|
+
Services are designed for easy testing with explicit inputs (arguments) and outputs (Response objects). No special test infrastructure needed.
|
|
6
|
+
|
|
7
|
+
## Schema Example Helpers
|
|
8
|
+
|
|
9
|
+
Servus provides test helpers that extract `example` values from your JSON schemas, making it easy to generate test fixtures without maintaining separate factories.
|
|
10
|
+
|
|
11
|
+
### Setup
|
|
12
|
+
|
|
13
|
+
Include the helpers in your test suite:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# spec/spec_helper.rb
|
|
17
|
+
require 'servus/testing'
|
|
18
|
+
|
|
19
|
+
RSpec.configure do |config|
|
|
20
|
+
config.include Servus::Testing::ExampleBuilders
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Using Schema Examples
|
|
25
|
+
|
|
26
|
+
Add `example` or `examples` keywords to your schemas:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
class ProcessPayment::Service < Servus::Base
|
|
30
|
+
schema(
|
|
31
|
+
arguments: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
user_id: { type: "integer", example: 123 },
|
|
35
|
+
amount: { type: "number", example: 100.0 },
|
|
36
|
+
currency: { type: "string", examples: ["USD", "EUR", "GBP"] }
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
result: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
transaction_id: { type: "string", example: "txn_abc123" },
|
|
43
|
+
status: { type: "string", example: "approved" }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then use the helpers in your tests:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
RSpec.describe ProcessPayment::Service do
|
|
54
|
+
it "processes payment successfully" do
|
|
55
|
+
# Extract examples from schema and override specific values
|
|
56
|
+
args = servus_arguments_example(ProcessPayment::Service, amount: 50.0)
|
|
57
|
+
# => { user_id: 123, amount: 50.0, currency: "USD" }
|
|
58
|
+
|
|
59
|
+
result = ProcessPayment::Service.call(**args)
|
|
60
|
+
|
|
61
|
+
expect(result).to be_success
|
|
62
|
+
expect(result.data.keys).to match_array(
|
|
63
|
+
servus_result_example(ProcessPayment::Service).data.keys
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "handles different currencies" do
|
|
68
|
+
%w[USD EUR GBP].each do |currency|
|
|
69
|
+
result = ProcessPayment::Service.call(
|
|
70
|
+
**servus_arguments_example(ProcessPayment::Service, currency: currency)
|
|
71
|
+
)
|
|
72
|
+
expect(result).to be_success
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Deep Merging
|
|
79
|
+
|
|
80
|
+
Overrides are deep-merged with schema examples, allowing you to override nested values:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# Schema has nested structure
|
|
84
|
+
args = servus_arguments_example(
|
|
85
|
+
CreateUser::Service,
|
|
86
|
+
user: { profile: { age: 35 } }
|
|
87
|
+
)
|
|
88
|
+
# => { user: { id: 1, profile: { name: 'Alice', age: 35 } } }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Available Helpers
|
|
92
|
+
|
|
93
|
+
- `servus_arguments_example(ServiceClass, **overrides)` - Returns hash of argument examples
|
|
94
|
+
- `servus_result_example(ServiceClass, **overrides)` - Returns Response object with result examples
|
|
95
|
+
|
|
96
|
+
## Basic Testing Pattern
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
RSpec.describe ProcessPayment::Service do
|
|
100
|
+
describe ".call" do
|
|
101
|
+
let(:user) { create(:user, balance: 1000) }
|
|
102
|
+
|
|
103
|
+
subject(:result) { described_class.call(user_id: user.id, amount: amount) }
|
|
104
|
+
|
|
105
|
+
context "with sufficient balance" do
|
|
106
|
+
let(:amount) { 50 }
|
|
107
|
+
|
|
108
|
+
it "processes payment" do
|
|
109
|
+
expect(result.success?).to be true
|
|
110
|
+
expect(result.data[:new_balance]).to eq(950)
|
|
111
|
+
expect(result.reload.balance).to eq(950)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
context "with insufficient balance" do
|
|
116
|
+
let(:amount) { 2000 }
|
|
117
|
+
|
|
118
|
+
it "returns failure" do
|
|
119
|
+
expect(result.success?).to be false
|
|
120
|
+
expect(result.error.message).to eq("Insufficient funds")
|
|
121
|
+
expect(result.error).to be_a(Servus::Support::Errors::ServiceError)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Testing Service Composition
|
|
129
|
+
|
|
130
|
+
When testing services that call other services, mock the child services:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
describe Users::CreateWithAccount::Service do
|
|
134
|
+
# Make local or global helpers to clean up tests
|
|
135
|
+
def servus_success_result(data)
|
|
136
|
+
Servus::Support::Response.new(success: true, data: data, error: nil)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "calls both create services" do
|
|
140
|
+
# Mock child services
|
|
141
|
+
allow(Users::Create::Service).to receive(:call).and_return(servus_success_result{ user: user })
|
|
142
|
+
allow(Accounts::Create::Service).to receive(:call).and_return(servus_success_result{ account: account })
|
|
143
|
+
|
|
144
|
+
result = described_class.call(email: "test@example.com", plan: "premium")
|
|
145
|
+
|
|
146
|
+
expect(Users::Create::Service).to have_received(:call)
|
|
147
|
+
expect(Accounts::Create::Service).to have_received(:call)
|
|
148
|
+
|
|
149
|
+
expect(result.success?).to be true
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Testing Schema Validation
|
|
155
|
+
|
|
156
|
+
Don't test that valid arguments pass validation - that's testing the framework. Do test that your schema catches invalid inputs:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
it "validates required fields" do
|
|
160
|
+
expect {
|
|
161
|
+
Service.call(invalid: "params")
|
|
162
|
+
}.to raise_error(Servus::Support::Errors::ValidationError, /required/)
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Testing Event Emission
|
|
167
|
+
|
|
168
|
+
Servus provides RSpec matchers for testing that services emit events.
|
|
169
|
+
|
|
170
|
+
### Setup
|
|
171
|
+
|
|
172
|
+
Include the matchers in your test suite:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
# spec/spec_helper.rb
|
|
176
|
+
require 'servus/testing'
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### emit_event Matcher
|
|
180
|
+
|
|
181
|
+
Assert that a block emits an event:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
RSpec.describe CreateUser::Service do
|
|
185
|
+
it 'emits user_created event on success' do
|
|
186
|
+
expect {
|
|
187
|
+
described_class.call(email: 'test@example.com', name: 'Test')
|
|
188
|
+
}.to emit_event(:user_created)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'emits event with expected payload' do
|
|
192
|
+
expect {
|
|
193
|
+
described_class.call(email: 'test@example.com', name: 'Test')
|
|
194
|
+
}.to emit_event(:user_created).with(hash_including(email: 'test@example.com'))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Using handler class instead of symbol
|
|
198
|
+
it 'emits to UserCreatedHandler' do
|
|
199
|
+
expect {
|
|
200
|
+
described_class.call(email: 'test@example.com', name: 'Test')
|
|
201
|
+
}.to emit_event(UserCreatedHandler)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### call_service Matcher
|
|
207
|
+
|
|
208
|
+
Assert that a handler invokes a service:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
RSpec.describe UserCreatedHandler do
|
|
212
|
+
let(:payload) { { user_id: 123, email: 'test@example.com' } }
|
|
213
|
+
|
|
214
|
+
it 'invokes SendWelcomeEmail::Service' do
|
|
215
|
+
expect {
|
|
216
|
+
described_class.handle(payload)
|
|
217
|
+
}.to call_service(SendWelcomeEmail::Service).with(user_id: 123)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it 'invokes service asynchronously' do
|
|
221
|
+
expect {
|
|
222
|
+
described_class.handle(payload)
|
|
223
|
+
}.to call_service(SendWelcomeEmail::Service).async
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Testing EventHandler Directly
|
|
229
|
+
|
|
230
|
+
Test handlers by calling their `handle` class method:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
RSpec.describe UserCreatedHandler do
|
|
234
|
+
let(:payload) { { user_id: 123, email: 'test@example.com' } }
|
|
235
|
+
|
|
236
|
+
before do
|
|
237
|
+
allow(SendWelcomeEmail::Service).to receive(:call_async)
|
|
238
|
+
allow(TrackAnalytics::Service).to receive(:call_async)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it 'invokes SendWelcomeEmail with mapped arguments' do
|
|
242
|
+
described_class.handle(payload)
|
|
243
|
+
|
|
244
|
+
expect(SendWelcomeEmail::Service)
|
|
245
|
+
.to have_received(:call_async)
|
|
246
|
+
.with(user_id: 123, email: 'test@example.com')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'invokes TrackAnalytics with event data' do
|
|
250
|
+
described_class.handle(payload)
|
|
251
|
+
|
|
252
|
+
expect(TrackAnalytics::Service)
|
|
253
|
+
.to have_received(:call_async)
|
|
254
|
+
.with(event: 'user_created', user_id: 123)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
context 'with conditional invocation' do
|
|
258
|
+
it 'skips premium rewards for non-premium users' do
|
|
259
|
+
allow(GrantPremiumRewards::Service).to receive(:call)
|
|
260
|
+
|
|
261
|
+
described_class.handle(payload.merge(premium: false))
|
|
262
|
+
|
|
263
|
+
expect(GrantPremiumRewards::Service).not_to have_received(:call)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Testing Event Emission from Handler
|
|
270
|
+
|
|
271
|
+
Test the `emit` class method:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
RSpec.describe UserCreatedHandler do
|
|
275
|
+
it 'emits the user_created event' do
|
|
276
|
+
expect {
|
|
277
|
+
described_class.emit({ user_id: 123, email: 'test@example.com' })
|
|
278
|
+
}.to emit_event(:user_created)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it 'validates payload against schema' do
|
|
282
|
+
expect {
|
|
283
|
+
described_class.emit({ invalid: 'payload' })
|
|
284
|
+
}.to raise_error(Servus::Support::Errors::ValidationError)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
```
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @title Integration / 3. Rails Integration
|
|
2
|
+
|
|
3
|
+
# Rails Integration
|
|
4
|
+
|
|
5
|
+
Servus core works in any Ruby application. Rails-specific features (async, controller helpers, generators) are optional additions that integrate with Rails conventions.
|
|
6
|
+
|
|
7
|
+
## Controller Integration
|
|
8
|
+
|
|
9
|
+
Use the `run_service` helper to call services from controllers with automatic error handling:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class UsersController < ApplicationController
|
|
13
|
+
include Servus::Helpers::ControllerHelpers
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
run_service(Users::Create::Service, user_params)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Failures automatically render JSON:
|
|
20
|
+
# { "error": { "code": "validation_error", "message": "..." } }
|
|
21
|
+
# with appropriate HTTP status code
|
|
22
|
+
#
|
|
23
|
+
# Success will go to view and service result will be available on @result
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Without the helper, handle responses manually:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
def create
|
|
31
|
+
result = Users::Create::Service.call(user_params)
|
|
32
|
+
if result.success?
|
|
33
|
+
render json: { user: result.data[:user] }, status: :created
|
|
34
|
+
else
|
|
35
|
+
render json: { error: result.error.api_error }, status: error_status(result.error)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Generator
|
|
41
|
+
|
|
42
|
+
Generate services with specs and schema files:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
rails generate servus:service process_payment
|
|
46
|
+
|
|
47
|
+
# Creates:
|
|
48
|
+
# app/services/process_payment/service.rb
|
|
49
|
+
# spec/services/process_payment/service_spec.rb
|
|
50
|
+
# app/schemas/services/process_payment/arguments.json
|
|
51
|
+
# app/schemas/services/process_payment/result.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Schema files are optional - delete them if you don't need validation.
|
|
55
|
+
|
|
56
|
+
## Autoloading
|
|
57
|
+
|
|
58
|
+
Servus follows Rails autoloading conventions. Services in `app/services/` are automatically loaded by Rails:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# app/services/users/create/service.rb
|
|
62
|
+
module Users
|
|
63
|
+
module Create
|
|
64
|
+
class Service < Servus::Base
|
|
65
|
+
# ...
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Rails autoloads this as Users::Create::Service
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Configure Servus in an initializer if needed:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# config/initializers/servus.rb
|
|
79
|
+
Servus.configure do |config|
|
|
80
|
+
config.schema_root = Rails.root.join('config/schemas')
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Most applications don't need any configuration.
|
|
85
|
+
|
|
86
|
+
## Background Jobs
|
|
87
|
+
|
|
88
|
+
Async execution requires ActiveJob setup. Configure your adapter:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# config/application.rb
|
|
92
|
+
config.active_job.queue_adapter = :sidekiq
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then use `.call_async`:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Users::SendWelcomeEmail::Service.call_async(user_id: user.id)
|
|
99
|
+
```
|