servus 0.1.3 → 0.1.4
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/.yardopts +6 -0
- data/CHANGELOG.md +7 -0
- data/IDEAS.md +5 -0
- data/READme.md +147 -42
- data/Rakefile +33 -0
- data/builds/servus-0.1.3.gem +0 -0
- data/builds/servus-0.1.4.gem +0 -0
- data/docs/core/1_overview.md +77 -0
- data/docs/core/2_architecture.md +92 -0
- data/docs/core/3_service_objects.md +121 -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/guides/1_common_patterns.md +90 -0
- data/docs/guides/2_migration_guide.md +175 -0
- data/docs/integration/1_configuration.md +51 -0
- data/docs/integration/2_testing.md +164 -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/service/service_generator.rb +64 -1
- data/lib/generators/servus/service/templates/service.rb.erb +1 -1
- data/lib/servus/base.rb +258 -57
- data/lib/servus/config.rb +58 -12
- 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/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 +120 -19
- data/lib/servus/testing/example_builders.rb +133 -0
- data/lib/servus/testing/example_extractor.rb +309 -0
- data/lib/servus/testing.rb +17 -0
- data/lib/servus/version.rb +1 -1
- metadata +117 -19
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @title Features / 1. Schema Validation
|
|
2
|
+
|
|
3
|
+
# Schema Validation
|
|
4
|
+
|
|
5
|
+
Servus provides optional JSON Schema validation for service arguments and results. Validation is opt-in - services work fine without schemas.
|
|
6
|
+
|
|
7
|
+
## How It Works
|
|
8
|
+
|
|
9
|
+
Define schemas using the `schema` DSL method (recommended) or as constants. The framework validates arguments before execution and results after execution. Invalid data raises `ValidationError`.
|
|
10
|
+
|
|
11
|
+
### Preferred: Schema DSL Method
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class ProcessPayment::Service < Servus::Base
|
|
15
|
+
schema(
|
|
16
|
+
arguments: {
|
|
17
|
+
type: "object",
|
|
18
|
+
required: ["user_id", "amount"],
|
|
19
|
+
properties: {
|
|
20
|
+
user_id: { type: "integer", example: 123 },
|
|
21
|
+
amount: { type: "number", minimum: 0.01, example: 100.0 }
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
result: {
|
|
25
|
+
type: "object",
|
|
26
|
+
required: ["transaction_id", "new_balance"],
|
|
27
|
+
properties: {
|
|
28
|
+
transaction_id: { type: "string", example: "txn_abc123" },
|
|
29
|
+
new_balance: { type: "number", example: 950.0 }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Pro tip:** Add `example` or `examples` keywords to your schemas. These values can be automatically extracted in tests using `servus_arguments_example()` and `servus_result_example()` helpers. See the [Testing documentation](../integration/testing.md#schema-example-helpers) for details.
|
|
37
|
+
|
|
38
|
+
You can define just one schema if needed:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class SendEmail::Service < Servus::Base
|
|
42
|
+
schema arguments: {
|
|
43
|
+
type: "object",
|
|
44
|
+
required: ["email", "subject"],
|
|
45
|
+
properties: {
|
|
46
|
+
email: { type: "string", format: "email" },
|
|
47
|
+
subject: { type: "string" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Alternative: Inline Constants
|
|
54
|
+
|
|
55
|
+
Constants are still supported for backwards compatibility:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class ProcessPayment::Service < Servus::Base
|
|
59
|
+
ARGUMENTS_SCHEMA = {
|
|
60
|
+
type: "object",
|
|
61
|
+
required: ["user_id", "amount"],
|
|
62
|
+
properties: {
|
|
63
|
+
user_id: { type: "integer" },
|
|
64
|
+
amount: { type: "number", minimum: 0.01 }
|
|
65
|
+
}
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
RESULT_SCHEMA = {
|
|
69
|
+
type: "object",
|
|
70
|
+
required: ["transaction_id", "new_balance"],
|
|
71
|
+
properties: {
|
|
72
|
+
transaction_id: { type: "string" },
|
|
73
|
+
new_balance: { type: "number" }
|
|
74
|
+
}
|
|
75
|
+
}.freeze
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## File-Based Schemas
|
|
80
|
+
|
|
81
|
+
For complex schemas, use JSON files instead of inline definitions. Create files at:
|
|
82
|
+
- `app/schemas/services/service_name/arguments.json`
|
|
83
|
+
- `app/schemas/services/service_name/result.json`
|
|
84
|
+
|
|
85
|
+
### Schema Lookup Precedence
|
|
86
|
+
|
|
87
|
+
Servus checks for schemas in this order:
|
|
88
|
+
1. **schema DSL method** (if defined)
|
|
89
|
+
2. **Inline constants** (ARGUMENTS_SCHEMA, RESULT_SCHEMA)
|
|
90
|
+
3. **JSON files** (in schema_root directory)
|
|
91
|
+
|
|
92
|
+
Schemas are cached after first load for performance.
|
|
93
|
+
|
|
94
|
+
## Three Layers of Validation
|
|
95
|
+
|
|
96
|
+
**Schema Validation** (Servus): Type safety and structure at service boundaries
|
|
97
|
+
|
|
98
|
+
**Business Rules** (Service Logic): Domain-specific constraints during execution
|
|
99
|
+
|
|
100
|
+
**Model Validation** (ActiveRecord): Database constraints before persistence
|
|
101
|
+
|
|
102
|
+
Each layer has a different purpose - don't duplicate validation across layers.
|
|
103
|
+
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
Change the schema file location if needed:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# config/initializers/servus.rb
|
|
110
|
+
Servus.configure do |config|
|
|
111
|
+
config.schema_root = Rails.root.join('config/schemas')
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Clear the schema cache during development when schemas change:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
Servus::Support::Validator.clear_cache!
|
|
119
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# @title Features / 2. Error Handling
|
|
2
|
+
|
|
3
|
+
# Error Handling
|
|
4
|
+
|
|
5
|
+
Servus distinguishes between expected business failures (return failure) and unexpected system errors (raise exceptions). This separation makes error handling predictable and explicit.
|
|
6
|
+
|
|
7
|
+
## Failures vs Exceptions
|
|
8
|
+
|
|
9
|
+
**Use `failure()`** for expected business conditions:
|
|
10
|
+
- User not found
|
|
11
|
+
- Insufficient balance
|
|
12
|
+
- Invalid state transition
|
|
13
|
+
|
|
14
|
+
**Use `error!()` or raise** for unexpected system errors:
|
|
15
|
+
- Database connection failure
|
|
16
|
+
- Nil reference error
|
|
17
|
+
- External API timeout
|
|
18
|
+
|
|
19
|
+
Failures return a Response object so callers can handle them. Exceptions halt execution and bubble up.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
def call
|
|
23
|
+
user = User.find_by(id: user_id)
|
|
24
|
+
return failure("User not found", type: NotFoundError) unless user
|
|
25
|
+
return failure("Insufficient funds") unless user.balance >= amount
|
|
26
|
+
|
|
27
|
+
user.update!(balance: user.balance - amount) # Raises on system error
|
|
28
|
+
success(user: user)
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Error Classes
|
|
33
|
+
|
|
34
|
+
All error classes inherit from `ServiceError` and map to HTTP status codes. Use them for API-friendly errors.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# Built-in errors
|
|
38
|
+
NotFoundError # 404
|
|
39
|
+
BadRequestError # 400
|
|
40
|
+
UnauthorizedError # 401
|
|
41
|
+
ForbiddenError # 403
|
|
42
|
+
ValidationError # 422
|
|
43
|
+
InternalServerError # 500
|
|
44
|
+
ServiceUnavailableError # 503
|
|
45
|
+
|
|
46
|
+
# Usage
|
|
47
|
+
failure("Resource not found", type: NotFoundError)
|
|
48
|
+
error!("Database corrupted", type: InternalServerError) # Raises exception
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each error has an `api_error` method returning `{ code: :symbol, message: "string" }` for JSON APIs.
|
|
52
|
+
|
|
53
|
+
## Declarative Exception Handling
|
|
54
|
+
|
|
55
|
+
Use `rescue_from` to convert specific exceptions into failures. Original exception details are preserved in error messages.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class CallExternalApi::Service < Servus::Base
|
|
59
|
+
rescue_from Net::HTTPError, Timeout::Error use: ServiceUnavailableError
|
|
60
|
+
rescue_from JSON::ParserError, use: BadRequestError
|
|
61
|
+
|
|
62
|
+
def call
|
|
63
|
+
response = http_client.get(url) # May raise
|
|
64
|
+
data = JSON.parse(response.body) # May raise
|
|
65
|
+
success(data: data)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# If Net::HTTPError is raised, service returns:
|
|
70
|
+
# Response(success: false, error: ServiceUnavailableError("[Net::HTTPError]: original message"))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `rescue_from` pattern keeps business logic clean while ensuring consistent error handling across services.
|
|
74
|
+
|
|
75
|
+
### Custom Error Handling with Blocks
|
|
76
|
+
|
|
77
|
+
For more control over error handling, provide a block to `rescue_from`. The block receives the exception and can return either success or failure:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
class ProcessPayment::Service < Servus::Base
|
|
81
|
+
# Custom failure with error details
|
|
82
|
+
rescue_from ActiveRecord::RecordInvalid do |exception|
|
|
83
|
+
failure("Payment failed: #{exception.record.errors.full_messages.join(', ')}",
|
|
84
|
+
type: ValidationError)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Recover from certain errors with success
|
|
88
|
+
rescue_from Stripe::CardError do |exception|
|
|
89
|
+
if exception.code == 'card_declined'
|
|
90
|
+
failure("Card was declined", type: BadRequestError)
|
|
91
|
+
else
|
|
92
|
+
# Log and continue for other card errors
|
|
93
|
+
Rails.logger.warn("Stripe error: #{exception.message}")
|
|
94
|
+
success(recovered: true, fallback_used: true)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def call
|
|
99
|
+
# Service logic that may raise exceptions
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The block has access to `success(data)` and `failure(message, type:)` methods. This allows conditional error handling and even recovering from exceptions.
|
|
105
|
+
|
|
106
|
+
## Custom Errors
|
|
107
|
+
|
|
108
|
+
Create domain-specific errors by inheriting from `ServiceError`:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class InsufficientFundsError < Servus::Support::Errors::ServiceError
|
|
112
|
+
DEFAULT_MESSAGE = "Insufficient funds"
|
|
113
|
+
|
|
114
|
+
def api_error
|
|
115
|
+
{ code: :insufficient_funds, message: message }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Usage
|
|
120
|
+
failure("Account balance too low", type: InsufficientFundsError)
|
|
121
|
+
```
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @title Features / 3. Async Execution
|
|
2
|
+
|
|
3
|
+
# Async Execution
|
|
4
|
+
|
|
5
|
+
Servus provides asynchronous execution via ActiveJob. Services run identically whether called sync or async - they're unaware of execution context.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Call `.call_async(**args)` instead of `.call(**args)` to execute in the background. The service is enqueued immediately and executed by a worker.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Synchronous
|
|
13
|
+
result = ProcessReport::Service.call(user_id: user.id, report_type: :monthly)
|
|
14
|
+
result.data[:report] # Available immediately
|
|
15
|
+
|
|
16
|
+
# Asynchronous
|
|
17
|
+
ProcessReport::Service.call_async(user_id: user.id, report_type: :monthly)
|
|
18
|
+
# Returns true if enqueued successfully
|
|
19
|
+
# Result not available (service hasn't run yet)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Services must accept JSON-serializable arguments for async execution (primitives, hashes, arrays, ActiveRecord objects via GlobalID). Complex objects like Procs won't work.
|
|
23
|
+
|
|
24
|
+
## Queue and Scheduling Options
|
|
25
|
+
|
|
26
|
+
Pass ActiveJob options to control execution:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
ProcessReport::Service.call_async(
|
|
30
|
+
user_id: user.id,
|
|
31
|
+
queue: :critical, # Specify queue
|
|
32
|
+
priority: 10, # Higher priority
|
|
33
|
+
wait: 5.minutes # Delay execution
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Result Handling
|
|
38
|
+
|
|
39
|
+
Async services can't return results to callers (the service hasn't executed yet). If you need results, implement persistence in the service:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class GenerateReport::Service < Servus::Base
|
|
43
|
+
def call
|
|
44
|
+
report_data = generate_report
|
|
45
|
+
|
|
46
|
+
# Persist result
|
|
47
|
+
Report.create!(
|
|
48
|
+
user_id: @user_id,
|
|
49
|
+
data: report_data,
|
|
50
|
+
status: 'completed'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Optionally notify user
|
|
54
|
+
UserMailer.report_ready(@user_id).deliver_now
|
|
55
|
+
|
|
56
|
+
success(data: report_data)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Controller creates placeholder, service updates it
|
|
61
|
+
report = Report.create!(user_id: user.id, status: 'pending')
|
|
62
|
+
GenerateReport::Service.call_async(user_id: user.id, report_id: report.id)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Error Handling
|
|
66
|
+
|
|
67
|
+
Failures (business logic) don't trigger retries - the job completes successfully but returns a failure Response.
|
|
68
|
+
|
|
69
|
+
Exceptions (system errors) trigger ActiveJob retry logic. Use `rescue_from` to convert transient errors into exceptions:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class Service < Servus::Base
|
|
73
|
+
rescue_from Net::HTTPError, Timeout::Error use: ServiceUnavailableError
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## When to Use Async
|
|
78
|
+
|
|
79
|
+
**Good candidates**: Email sending, report generation, data imports, long-running API calls, cleanup tasks
|
|
80
|
+
|
|
81
|
+
**Poor candidates**: Operations requiring immediate feedback, fast operations (<100ms), critical path operations where user waits for result
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @title Features / 4. Logging
|
|
2
|
+
|
|
3
|
+
# Logging
|
|
4
|
+
|
|
5
|
+
Servus automatically logs all service executions with timing information. No instrumentation code needed in services.
|
|
6
|
+
|
|
7
|
+
## What Gets Logged
|
|
8
|
+
|
|
9
|
+
**Service calls** (DEBUG): Service class name and arguments
|
|
10
|
+
```
|
|
11
|
+
[Servus] Users::Create::Service called with {:email=>"user@example.com", :name=>"John"}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**Successful completions** (INFO): Service class name and duration
|
|
15
|
+
```
|
|
16
|
+
[Servus] Users::Create::Service completed successfully in 0.0243s
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Failures** (WARN): Service class name, error type, message, and duration
|
|
20
|
+
```
|
|
21
|
+
[Servus] Users::Create::Service failed with NotFoundError: User not found (0.0125s)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Exceptions** (ERROR): Service class name, exception type, message, and duration
|
|
25
|
+
```
|
|
26
|
+
[Servus] Users::Create::Service raised ArgumentError: Missing required field (0.0089s)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Log Levels
|
|
30
|
+
|
|
31
|
+
Servus uses Rails.logger and respects application log level configuration:
|
|
32
|
+
|
|
33
|
+
- **DEBUG**: Shows arguments (use in development, hide in production to avoid logging sensitive data)
|
|
34
|
+
- **INFO**: Shows completions (normal operations)
|
|
35
|
+
- **WARN**: Shows business failures
|
|
36
|
+
- **ERROR**: Shows system exceptions
|
|
37
|
+
|
|
38
|
+
Set production log level to INFO to hide argument logging:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# config/environments/production.rb
|
|
42
|
+
config.log_level = :info
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Sensitive Data
|
|
46
|
+
|
|
47
|
+
Arguments are logged at DEBUG level. In production, either:
|
|
48
|
+
1. Set log level to INFO (recommended)
|
|
49
|
+
2. Use Rails parameter filtering: `config.filter_parameters += [:password, :ssn, :credit_card]`
|
|
50
|
+
3. Pass IDs instead of full objects: `Service.call(user_id: 1)` not `Service.call(user: user_object)`
|
|
51
|
+
|
|
52
|
+
## Integration with Logging Tools
|
|
53
|
+
|
|
54
|
+
The `[Servus]` prefix makes service logs easy to grep and filter:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Find all service calls
|
|
58
|
+
grep "\[Servus\]" production.log
|
|
59
|
+
|
|
60
|
+
# Find slow services
|
|
61
|
+
grep "completed" production.log | grep "Servus" | awk '{print $NF}' | sort -n
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Servus logs work with structured logging tools (Lograge, Datadog, Splunk) without modification.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @title Guides / 1. Common Patterns
|
|
2
|
+
|
|
3
|
+
# Common Patterns
|
|
4
|
+
|
|
5
|
+
Common architectural patterns for using Servus effectively.
|
|
6
|
+
|
|
7
|
+
## Parent-Child Services
|
|
8
|
+
|
|
9
|
+
When one service orchestrates multiple sub-operations, decide on transaction boundaries and error propagation.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class Orders::Checkout::Service < Servus::Base
|
|
13
|
+
def call
|
|
14
|
+
ActiveRecord::Base.transaction do
|
|
15
|
+
# Create order
|
|
16
|
+
order_result = Orders::Create::Service.call(order_params)
|
|
17
|
+
return order_result unless order_result.success?
|
|
18
|
+
|
|
19
|
+
# Charge payment
|
|
20
|
+
payment_result = Payments::Charge::Service.call(
|
|
21
|
+
user_id: @user_id,
|
|
22
|
+
amount: order_result.data[:order].total
|
|
23
|
+
)
|
|
24
|
+
return payment_result unless payment_result.success?
|
|
25
|
+
|
|
26
|
+
# Update inventory
|
|
27
|
+
inventory_result = Inventory::Reserve::Service.call(
|
|
28
|
+
order_id: order_result.data[:order].id
|
|
29
|
+
)
|
|
30
|
+
return inventory_result unless inventory_result.success?
|
|
31
|
+
|
|
32
|
+
success(order: order_result.data[:order])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Use parent transaction when**: All children must succeed or all roll back (atomic operation)
|
|
39
|
+
|
|
40
|
+
**Use child transactions when**: Children can succeed independently (partial success acceptable)
|
|
41
|
+
|
|
42
|
+
## Async with Result Persistence
|
|
43
|
+
|
|
44
|
+
Store async results in database for later retrieval:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# Controller creates placeholder
|
|
48
|
+
report = Report.create!(user_id: user.id, status: 'pending')
|
|
49
|
+
GenerateReport::Service.call_async(report_id: report.id)
|
|
50
|
+
|
|
51
|
+
# Service updates record
|
|
52
|
+
class GenerateReport::Service < Servus::Base
|
|
53
|
+
def call
|
|
54
|
+
report = Report.find(@report_id)
|
|
55
|
+
data = generate_report_data
|
|
56
|
+
|
|
57
|
+
report.update!(data: data, status: 'completed')
|
|
58
|
+
success(report: report)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Idempotent Services
|
|
64
|
+
|
|
65
|
+
Use database constraints to make services idempotent:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class Users::Create::Service < Servus::Base
|
|
69
|
+
def call
|
|
70
|
+
# Unique constraint on email prevents duplicates
|
|
71
|
+
user = User.create!(email: @email, name: @name)
|
|
72
|
+
success(user: user)
|
|
73
|
+
rescue ActiveRecord::RecordNotUnique
|
|
74
|
+
user = User.find_by!(email: @email)
|
|
75
|
+
success(user: user) # Return existing user, not error
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or check for existing resources explicitly:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
def call
|
|
84
|
+
existing = User.find_by(email: @email)
|
|
85
|
+
return success(user: existing) if existing
|
|
86
|
+
|
|
87
|
+
user = User.create!(email: @email, name: @name)
|
|
88
|
+
success(user: user)
|
|
89
|
+
end
|
|
90
|
+
```
|
|
@@ -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,51 @@
|
|
|
1
|
+
# @title Integration / 1. Configuration
|
|
2
|
+
|
|
3
|
+
# Configuration
|
|
4
|
+
|
|
5
|
+
Servus works without configuration. One optional setting exists for schema file location.
|
|
6
|
+
|
|
7
|
+
## Schema Root
|
|
8
|
+
|
|
9
|
+
By default, Servus looks for schema JSON files in `Rails.root/app/schemas/services` (or `./app/schemas/services` in non-Rails apps).
|
|
10
|
+
|
|
11
|
+
Change the location if needed:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# config/initializers/servus.rb
|
|
15
|
+
Servus.configure do |config|
|
|
16
|
+
config.schema_root = Rails.root.join('config/schemas')
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This affects legacy file-based schemas only - schemas defined via the `schema` DSL method do not use files.
|
|
21
|
+
|
|
22
|
+
## Schema Cache
|
|
23
|
+
|
|
24
|
+
Schemas are cached after first load for performance. Clear the cache during development when schemas change:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
Servus::Support::Validator.clear_cache!
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
In production, schemas are deployed with code - no need to clear cache.
|
|
31
|
+
|
|
32
|
+
## Log Level
|
|
33
|
+
|
|
34
|
+
Servus uses `Rails.logger` (or stdout in non-Rails apps). Control logging via Rails configuration:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# config/environments/production.rb
|
|
38
|
+
config.log_level = :info # Hides DEBUG argument logs
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## ActiveJob Configuration
|
|
42
|
+
|
|
43
|
+
Async execution uses ActiveJob. Configure your adapter:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# config/application.rb
|
|
47
|
+
config.active_job.queue_adapter = :sidekiq
|
|
48
|
+
config.active_job.default_queue_name = :default
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Servus respects ActiveJob queue configuration - no Servus-specific setup needed.
|