servus 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +73 -10
- data/docs/core/1_overview.md +6 -2
- data/docs/core/3_service_objects.md +33 -0
- data/docs/features/1_schema_validation.md +43 -1
- data/docs/features/2_error_handling.md +9 -1
- data/docs/features/7_lazy_resolvers.md +238 -0
- data/docs/guides/2_migration_guide.md +51 -1
- data/docs/integration/2_testing.md +18 -1
- data/lib/servus/base.rb +21 -6
- data/lib/servus/event_handler.rb +27 -12
- data/lib/servus/extensions/lazily/call.rb +82 -0
- data/lib/servus/extensions/lazily/errors.rb +37 -0
- data/lib/servus/extensions/lazily/ext.rb +23 -0
- data/lib/servus/extensions/lazily/resolver.rb +32 -0
- data/lib/servus/railtie.rb +7 -1
- data/lib/servus/support/data_object.rb +80 -0
- data/lib/servus/support/errors.rb +291 -23
- data/lib/servus/support/response.rb +12 -1
- data/lib/servus/support/validator.rb +28 -14
- data/lib/servus/testing/example_builders.rb +22 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +1 -0
- metadata +12 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58e6098b9ea316c670b5b8aa6f61828d961cc3c8ad6e72357d71c442dfe75151
|
|
4
|
+
data.tar.gz: 215f0f790ad36575b0b9de3f956cf585f19051c16d5250ada1ae152a969dd1d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48142cd74cbd766846ccd9d8bf4a71a8d67136cbd60bdc948ddda6a0fa99f7e6072351b6ff98958500a6d84e49d1cb7d122e8e435efeb8b1384d39a4be9932ad
|
|
7
|
+
data.tar.gz: 13899ee4220e2e25b888eae9b6675a77780770628717bc11a2abda9bdd82e49b0798d70c954f91e5649a3bc281d9b3ad0e50dce41374f3c2ebc42125d7321848
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
## [0.3.0] - 2026-04-03
|
|
2
|
+
|
|
3
|
+
### Breaking Changes
|
|
4
|
+
|
|
5
|
+
- **Failure responses can now carry data**: `failure()` accepts an optional `data:` kwarg. Previously,
|
|
6
|
+
`result.data` was guaranteed to be `nil` on failure. Code that checks `result.data` for truthiness
|
|
7
|
+
to determine success/failure must switch to `result.success?` or `result.failure?`.
|
|
8
|
+
See the [Migration Guide](docs/guides/2_migration_guide.md#migrating-to-030) for details.
|
|
9
|
+
- **`Response#with_data` removed**: Replaced by the `data:` kwarg on `failure()`. The `with_data` method
|
|
10
|
+
allowed arbitrary mutation of responses after creation, bypassing schema validation.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`lazily` resolver DSL**: Declare lazy record resolvers on services with `lazily :user, finds: User`.
|
|
15
|
+
Accepts either an ID or an already-loaded instance — resolves on first access, memoizes the result.
|
|
16
|
+
Supports custom columns (`by: :uuid`), array input (via `.where`), and dry-initializer compatibility.
|
|
17
|
+
Loaded as an extension via Railtie when ActiveRecord is present.
|
|
18
|
+
- **Failure data support**: `failure()` accepts an optional `data:` keyword argument for attaching
|
|
19
|
+
structured data to failure responses (e.g., `failure("Declined", data: { reason: "insufficient_funds" })`).
|
|
20
|
+
Defaults to `nil` for backwards compatibility with services that don't use it.
|
|
21
|
+
- **Failure schema validation**: Define a `failure` schema via the `schema` DSL, `FAILURE_SCHEMA` constant,
|
|
22
|
+
or `failure.json` file. When present, failure response data is validated against it — just like success
|
|
23
|
+
results are validated against `result` schemas.
|
|
24
|
+
- **`servus_failure_example` test helper**: Extracts example values from a service's `failure` schema,
|
|
25
|
+
returning a failure `Response` for use in tests.
|
|
26
|
+
- **`failure?` predicate on Response**: Complement to `success?` for cleaner conditional handling.
|
|
27
|
+
- **`DataObject` wrapper for response data**: Hash data returned by services is wrapped in a read-only
|
|
28
|
+
`DataObject` that supports accessor-style access (`result.data.user.email`) alongside bracket access
|
|
29
|
+
(`result.data[:user]`). Nested Hashes and Hashes inside Arrays are recursively wrapped. Non-Hash values
|
|
30
|
+
(models, nil) pass through unwrapped.
|
|
31
|
+
|
|
32
|
+
## [0.2.1] - 2025-12-20
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **EventHandler Scheduling Options**: Extended the `invoke` DSL to support ActiveJob scheduling options
|
|
37
|
+
- `:wait` - delay execution (e.g., `5.minutes`)
|
|
38
|
+
- `:wait_until` - schedule for specific time
|
|
39
|
+
- `:priority` - job priority
|
|
40
|
+
- `:job_options` - additional ActiveJob options
|
|
41
|
+
- Options are passed through to `call_async`, enabling delayed and scheduled event handling
|
|
42
|
+
|
|
43
|
+
- **Custom HTTP Error Classes**: Added granular error classes for HTTP status handling
|
|
44
|
+
- Error classes for common HTTP statuses (400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504)
|
|
45
|
+
- Each error class has appropriate `http_status` and default `code`/`message`
|
|
46
|
+
- Enables more precise error handling and cleaner rescue blocks
|
|
47
|
+
|
|
1
48
|
## [0.2.0] - 2025-12-16
|
|
2
49
|
|
|
3
50
|
### Added
|
|
@@ -85,21 +132,37 @@
|
|
|
85
132
|
- Enhanced Railtie to auto-load event handlers and clear the event bus on reload in development
|
|
86
133
|
|
|
87
134
|
## [0.1.4] - 2025-11-21
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
-
|
|
92
|
-
|
|
135
|
+
|
|
136
|
+
### Added
|
|
137
|
+
|
|
138
|
+
- **Schema DSL method**: `schema arguments: {...}, result: {...}` syntax for cleaner schema definition.
|
|
139
|
+
Fully backwards compatible with existing `ARGUMENTS_SCHEMA` and `RESULT_SCHEMA` constants.
|
|
140
|
+
- **Test helpers**: `servus_arguments_example` and `servus_result_example` for extracting example values
|
|
141
|
+
from schemas in tests
|
|
142
|
+
- **`rescue_from` block support**: Override the default failure handler with a custom block
|
|
143
|
+
- **YARD documentation**: Configuration with README homepage and markdown file support
|
|
144
|
+
|
|
145
|
+
### Fixed
|
|
146
|
+
|
|
147
|
+
- YARD link resolution warnings in documentation
|
|
93
148
|
|
|
94
149
|
## [0.1.3] - 2025-10-10
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
|
|
151
|
+
### Added
|
|
152
|
+
|
|
153
|
+
- **`call_async`**: Enqueue service calls as background jobs via ActiveJob
|
|
154
|
+
- **`Async::Job`**: Job class for async enqueueing with support for ActiveJob `set` options
|
|
97
155
|
|
|
98
156
|
## [0.1.1] - 2025-08-20
|
|
99
157
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
-
|
|
158
|
+
### Added
|
|
159
|
+
|
|
160
|
+
- **`rescue_from`**: Rescue from standard errors and convert them to failure responses with custom error types
|
|
161
|
+
- **Controller helpers**: `run_service` and `render_service_object_error` in `Servus::Helpers::ControllerHelpers`
|
|
162
|
+
|
|
163
|
+
### Fixed
|
|
164
|
+
|
|
165
|
+
- All rubocop warnings
|
|
103
166
|
|
|
104
167
|
## [0.1.0] - 2025-04-28
|
|
105
168
|
|
data/docs/core/1_overview.md
CHANGED
|
@@ -39,11 +39,15 @@ Services return `Response` objects instead of raising exceptions for business fa
|
|
|
39
39
|
```ruby
|
|
40
40
|
result = SomeService.call(params)
|
|
41
41
|
if result.success?
|
|
42
|
-
result.data
|
|
42
|
+
result.data # DataObject wrapping the success data
|
|
43
|
+
result.data[:user] # bracket access
|
|
44
|
+
result.data.user # accessor access
|
|
45
|
+
result.data.user.email # nested accessor access
|
|
43
46
|
else
|
|
44
|
-
result.error
|
|
47
|
+
result.error # ServiceError instance
|
|
45
48
|
result.error.message
|
|
46
49
|
result.error.api_error # { code: :symbol, message: "string" }
|
|
50
|
+
result.data # optional failure data (nil unless data: kwarg was used)
|
|
47
51
|
end
|
|
48
52
|
```
|
|
49
53
|
|
|
@@ -35,6 +35,39 @@ result.success? # => true
|
|
|
35
35
|
result.data[:user] # => #<User>
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Accessing Response Data
|
|
39
|
+
|
|
40
|
+
Response data can be accessed with bracket syntax or accessor-style methods. Both are fully supported — use whichever reads better in context.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
result = Users::Create::Service.call(email: "user@example.com", name: "John")
|
|
44
|
+
|
|
45
|
+
# Bracket access
|
|
46
|
+
result.data[:user] # => #<User>
|
|
47
|
+
result.data[:user].email # => "user@example.com"
|
|
48
|
+
|
|
49
|
+
# Accessor access
|
|
50
|
+
result.data.user # => #<User>
|
|
51
|
+
result.data.user.email # => "user@example.com"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Nested Hashes are wrapped automatically, so accessor chains work at any depth:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
result = Orders::Create::Service.call(items: [...])
|
|
58
|
+
|
|
59
|
+
result.data.order.shipping.address.city # => "Berlin"
|
|
60
|
+
result.data[:order][:shipping] # also works
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Arrays of Hashes are also wrapped, so you can access elements naturally:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
result.data.order.items.first.sku # => "A1"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Non-Hash values like model instances, strings, and integers pass through unchanged — accessor access on those values uses the object's own methods.
|
|
70
|
+
|
|
38
71
|
## Service Composition
|
|
39
72
|
|
|
40
73
|
Services can call other services. Use the returned Response to decide whether to continue or propagate the failure.
|
|
@@ -28,6 +28,12 @@ class ProcessPayment::Service < Servus::Base
|
|
|
28
28
|
transaction_id: { type: "string", example: "txn_abc123" },
|
|
29
29
|
new_balance: { type: "number", example: 950.0 }
|
|
30
30
|
}
|
|
31
|
+
},
|
|
32
|
+
failure: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
reason: { type: "string", example: "insufficient_funds" }
|
|
36
|
+
}
|
|
31
37
|
}
|
|
32
38
|
)
|
|
33
39
|
end
|
|
@@ -73,6 +79,13 @@ class ProcessPayment::Service < Servus::Base
|
|
|
73
79
|
new_balance: { type: "number" }
|
|
74
80
|
}
|
|
75
81
|
}.freeze
|
|
82
|
+
|
|
83
|
+
FAILURE_SCHEMA = {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
reason: { type: "string" }
|
|
87
|
+
}
|
|
88
|
+
}.freeze
|
|
76
89
|
end
|
|
77
90
|
```
|
|
78
91
|
|
|
@@ -81,12 +94,13 @@ end
|
|
|
81
94
|
For complex schemas, use JSON files instead of inline definitions. Create files at:
|
|
82
95
|
- `app/schemas/services/service_name/arguments.json`
|
|
83
96
|
- `app/schemas/services/service_name/result.json`
|
|
97
|
+
- `app/schemas/services/service_name/failure.json`
|
|
84
98
|
|
|
85
99
|
### Schema Lookup Precedence
|
|
86
100
|
|
|
87
101
|
Servus checks for schemas in this order:
|
|
88
102
|
1. **schema DSL method** (if defined)
|
|
89
|
-
2. **Inline constants** (ARGUMENTS_SCHEMA, RESULT_SCHEMA)
|
|
103
|
+
2. **Inline constants** (ARGUMENTS_SCHEMA, RESULT_SCHEMA, FAILURE_SCHEMA)
|
|
90
104
|
3. **JSON files** (in schema_root directory)
|
|
91
105
|
|
|
92
106
|
Schemas are cached after first load for performance.
|
|
@@ -101,6 +115,34 @@ Schemas are cached after first load for performance.
|
|
|
101
115
|
|
|
102
116
|
Each layer has a different purpose - don't duplicate validation across layers.
|
|
103
117
|
|
|
118
|
+
## Failure Data Validation
|
|
119
|
+
|
|
120
|
+
Services can optionally attach structured data to failure responses using the `data:` keyword argument on `failure()`. When a `failure` schema is defined, this data is validated against it — just like success results are validated against `result` schemas.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
class ProcessPayment::Service < Servus::Base
|
|
124
|
+
schema(
|
|
125
|
+
failure: {
|
|
126
|
+
type: "object",
|
|
127
|
+
required: ["reason"],
|
|
128
|
+
properties: {
|
|
129
|
+
reason: { type: "string" },
|
|
130
|
+
decline_code: { type: "string" }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def call
|
|
136
|
+
return failure("Card declined", data: { reason: "insufficient_funds", decline_code: "do_not_honor" })
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Failure data validation is skipped when:
|
|
142
|
+
- No `failure` schema is defined
|
|
143
|
+
- The failure response has no data (`data: nil`, the default)
|
|
144
|
+
- The response is a success
|
|
145
|
+
|
|
104
146
|
## Configuration
|
|
105
147
|
|
|
106
148
|
Change the schema file location if needed:
|
|
@@ -29,6 +29,14 @@ def call
|
|
|
29
29
|
end
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
Failures can optionally carry structured data using the `data:` keyword argument. When a `failure` schema is defined on the service, this data is validated against it.
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
def call
|
|
36
|
+
return failure("Approval required", data: { requires_human_approval: true, ai_approved: true })
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
32
40
|
## Error Classes
|
|
33
41
|
|
|
34
42
|
All error classes inherit from `ServiceError` and map to HTTP status codes. Use them for API-friendly errors.
|
|
@@ -101,7 +109,7 @@ class ProcessPayment::Service < Servus::Base
|
|
|
101
109
|
end
|
|
102
110
|
```
|
|
103
111
|
|
|
104
|
-
The block has access to `success(data)` and `failure(message, type:)` methods. This allows conditional error handling and even recovering from exceptions.
|
|
112
|
+
The block has access to `success(data)` and `failure(message, data:, type:)` methods. This allows conditional error handling and even recovering from exceptions.
|
|
105
113
|
|
|
106
114
|
## Custom Errors
|
|
107
115
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# @title Features / 7. Lazy Resolvers
|
|
2
|
+
|
|
3
|
+
# Lazy Resolvers
|
|
4
|
+
|
|
5
|
+
Services often accept record IDs as inputs and query for the record inside `call`. This is necessary for async execution — ActiveJob serializes arguments, so you can't pass ActiveRecord objects through a job. But when calling synchronously with an already-loaded record, re-querying is wasteful.
|
|
6
|
+
|
|
7
|
+
The `lazily` DSL solves this. A service declares what records it needs, and the resolver handles both cases transparently: pass an ID and it queries; pass an instance and it skips the query.
|
|
8
|
+
|
|
9
|
+
## The Problem
|
|
10
|
+
|
|
11
|
+
Without `lazily`, you write this pattern repeatedly:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class ProcessPayment::Service < Servus::Base
|
|
15
|
+
def initialize(user_id:, amount:)
|
|
16
|
+
@user_id = user_id
|
|
17
|
+
@amount = amount
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
user = User.find(@user_id) # Always queries, even if caller had the record
|
|
22
|
+
# ...
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The caller can't pass a loaded record — `user_id:` expects an integer. And if you change the param to `user:`, it breaks async execution.
|
|
28
|
+
|
|
29
|
+
## Basic Usage
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class ProcessPayment::Service < Servus::Base
|
|
33
|
+
lazily :user, finds: User
|
|
34
|
+
|
|
35
|
+
def initialize(user:, amount:)
|
|
36
|
+
@user = user
|
|
37
|
+
@amount = amount
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call
|
|
41
|
+
return failure("Insufficient funds") unless user.balance >= @amount
|
|
42
|
+
|
|
43
|
+
user.update!(balance: user.balance - @amount)
|
|
44
|
+
success(user: user, new_balance: user.balance)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The param is named `user:` — callers pass whatever they have:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# Sync with a loaded record — no query
|
|
53
|
+
ProcessPayment::Service.call(user: current_user, amount: 50)
|
|
54
|
+
|
|
55
|
+
# Async with an ID — resolves via User.find(123)
|
|
56
|
+
ProcessPayment::Service.call_async(user: user.id, amount: 50)
|
|
57
|
+
|
|
58
|
+
# Sync with an ID — also works
|
|
59
|
+
ProcessPayment::Service.call(user: 123, amount: 50)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## DSL Signature
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
lazily :name, finds: ModelClass # default: ModelClass.find(value)
|
|
66
|
+
lazily :name, finds: ModelClass, by: :column # ModelClass.find_by!(column: value)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Parameter | Description |
|
|
70
|
+
|-----------|-------------|
|
|
71
|
+
| `:name` | The keyword argument name and the accessor method name |
|
|
72
|
+
| `finds:` | The model class constant (e.g., `User`, `Account`) |
|
|
73
|
+
| `by:` | Lookup column. Defaults to `:id`. When set, uses `.find_by!` instead of `.find` |
|
|
74
|
+
|
|
75
|
+
## Resolution Behavior
|
|
76
|
+
|
|
77
|
+
The resolver checks the input type and acts accordingly:
|
|
78
|
+
|
|
79
|
+
| Input | Behavior |
|
|
80
|
+
|-------|----------|
|
|
81
|
+
| Instance of target class | Returned directly — no query |
|
|
82
|
+
| Integer, String, or other scalar | Resolved via `.find(value)` or `.find_by!(column: value)` |
|
|
83
|
+
| Array | Resolved via `.where(column => values)` |
|
|
84
|
+
| `nil` | Raises `NotFoundError` immediately |
|
|
85
|
+
|
|
86
|
+
Resolution is **lazy** — it only happens when the accessor method is first called inside `call`, not during `initialize`. If a service never calls the accessor, no query is made.
|
|
87
|
+
|
|
88
|
+
Resolution is **memoized** — the resolved record is written back to the instance variable. Subsequent calls return the same object without re-querying.
|
|
89
|
+
|
|
90
|
+
## Custom Column Lookup
|
|
91
|
+
|
|
92
|
+
Use `by:` to look up by a column other than `:id`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class FindAccount::Service < Servus::Base
|
|
96
|
+
lazily :account, finds: Account, by: :uuid
|
|
97
|
+
|
|
98
|
+
def initialize(account:)
|
|
99
|
+
@account = account
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def call
|
|
103
|
+
success(account: account)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Resolves via Account.find_by!(uuid: "abc-def-123")
|
|
108
|
+
FindAccount::Service.call(account: "abc-def-123")
|
|
109
|
+
|
|
110
|
+
# Passes through an Account instance directly
|
|
111
|
+
FindAccount::Service.call(account: loaded_account)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The `by:` column accepts any value type — strings, integers, UUIDs — whatever the column expects.
|
|
115
|
+
|
|
116
|
+
## Array Input
|
|
117
|
+
|
|
118
|
+
When the input is an Array, the resolver uses `.where` instead of `.find`:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
class BulkNotify::Service < Servus::Base
|
|
122
|
+
lazily :users, finds: User
|
|
123
|
+
|
|
124
|
+
def initialize(users:, message:)
|
|
125
|
+
@users = users
|
|
126
|
+
@message = message
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def call
|
|
130
|
+
users.each { |u| notify(u, @message) }
|
|
131
|
+
success(notified: users.count)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Resolves via User.where(id: [1, 2, 3])
|
|
136
|
+
BulkNotify::Service.call(users: [1, 2, 3], message: "Hello")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Arrays with a custom `by:` column use `.where(column => values)`.
|
|
140
|
+
|
|
141
|
+
Empty arrays return an empty relation — no error is raised.
|
|
142
|
+
|
|
143
|
+
## Multiple Resolvers
|
|
144
|
+
|
|
145
|
+
A service can declare multiple resolvers. Each resolves independently and memoizes separately:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
class TransferFunds::Service < Servus::Base
|
|
149
|
+
lazily :sender, finds: User
|
|
150
|
+
lazily :receiver, finds: User
|
|
151
|
+
lazily :account, finds: Account, by: :uuid
|
|
152
|
+
|
|
153
|
+
def initialize(sender:, receiver:, account:, amount:)
|
|
154
|
+
@sender = sender
|
|
155
|
+
@receiver = receiver
|
|
156
|
+
@account = account
|
|
157
|
+
@amount = amount
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def call
|
|
161
|
+
# Each resolver triggers independently on first access
|
|
162
|
+
success(from: sender.name, to: receiver.name, account: account.uuid)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Error States
|
|
168
|
+
|
|
169
|
+
### Nil Input
|
|
170
|
+
|
|
171
|
+
Raises `Servus::Extensions::Lazily::Errors::NotFoundError` immediately. The error message includes the param name and target class:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
Couldn't find User (user was nil)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This is always a bug at the call site — the resolver never silently returns nil.
|
|
178
|
+
|
|
179
|
+
### Missing Record
|
|
180
|
+
|
|
181
|
+
`.find` and `.find_by!` raise `ActiveRecord::RecordNotFound` as usual. The resolver does not catch or wrap these errors — they propagate normally.
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
# Raises ActiveRecord::RecordNotFound: Couldn't find User with 'id'=999
|
|
185
|
+
ProcessPayment::Service.call(user: 999, amount: 50)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Use `rescue_from` if you want to convert these to failure responses:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
class ProcessPayment::Service < Servus::Base
|
|
192
|
+
lazily :user, finds: User
|
|
193
|
+
rescue_from ActiveRecord::RecordNotFound, use: NotFoundError
|
|
194
|
+
|
|
195
|
+
# ...
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Empty Array
|
|
200
|
+
|
|
201
|
+
Returns an empty ActiveRecord relation. No error is raised — this is intentional for batch operations where an empty set is valid.
|
|
202
|
+
|
|
203
|
+
## Async Compatibility
|
|
204
|
+
|
|
205
|
+
The `lazily` pattern is designed for services that run both synchronously and asynchronously:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
# Controller (sync) — pass the loaded record
|
|
209
|
+
def create
|
|
210
|
+
run_service(ProcessPayment::Service, user: current_user, amount: params[:amount])
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Background job (async) — pass the ID
|
|
214
|
+
ProcessPayment::Service.call_async(user: user.id, amount: 50)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Both paths use the same service code. The resolver handles the difference.
|
|
218
|
+
|
|
219
|
+
## dry-initializer Compatibility
|
|
220
|
+
|
|
221
|
+
`lazily` works alongside dry-initializer. The resolver defines its accessor on a prepended module, which takes priority over dry-initializer's generated method. It reads from the `@name` instance variable that dry-initializer sets:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class ProcessPayment::Service < Servus::Base
|
|
225
|
+
option :user
|
|
226
|
+
option :amount
|
|
227
|
+
|
|
228
|
+
lazily :user, finds: User
|
|
229
|
+
|
|
230
|
+
def call
|
|
231
|
+
success(user: user, charged: amount)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Requirements
|
|
237
|
+
|
|
238
|
+
`lazily` is an ActiveRecord extension. It loads automatically via Railtie when ActiveRecord is present in your application. It is not available in pure Ruby applications without ActiveRecord.
|
|
@@ -2,7 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
# Migration Guide
|
|
4
4
|
|
|
5
|
-
Strategies for adopting Servus in existing Rails applications.
|
|
5
|
+
Strategies for adopting Servus in existing Rails applications, and version-specific migration notes.
|
|
6
|
+
|
|
7
|
+
## Migrating to 0.3.0
|
|
8
|
+
|
|
9
|
+
### Breaking: `result.data` is no longer guaranteed nil on failure
|
|
10
|
+
|
|
11
|
+
In 0.2.x, `failure()` always set `result.data` to `nil`. Some code relied on this to distinguish success from failure:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# ❌ Unsafe in 0.3.0 — result.data can be non-nil on failure
|
|
15
|
+
if result.data
|
|
16
|
+
# handle success
|
|
17
|
+
else
|
|
18
|
+
# handle failure
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
In 0.3.0, `failure()` accepts an optional `data:` kwarg, so failure responses can carry structured data. Use `result.success?` or `result.failure?` instead:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# ✅ Correct — always use success? or failure?
|
|
26
|
+
if result.success?
|
|
27
|
+
render json: result.data, status: :ok
|
|
28
|
+
else
|
|
29
|
+
render json: { error: result.error.api_error, details: result.data }, status: result.error.http_status
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**How to find affected code**: Search your codebase for patterns like `if result.data`, `result.data.present?`, or `result.data.nil?` used as success/failure checks. Replace them with `result.success?` or `result.failure?`.
|
|
34
|
+
|
|
35
|
+
### Removed: `Response#with_data`
|
|
36
|
+
|
|
37
|
+
`with_data` was removed in favor of the `data:` kwarg on `failure()`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# ❌ 0.2.x — no longer available
|
|
41
|
+
failure("Declined").tap { |r| r.with_data(reason: "insufficient_funds") }
|
|
42
|
+
|
|
43
|
+
# ✅ 0.3.0
|
|
44
|
+
failure("Declined", data: { reason: "insufficient_funds" })
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### New: DataObject wrapping
|
|
48
|
+
|
|
49
|
+
Response data is now wrapped in a `DataObject` when it is a Hash. This is backwards compatible — `result.data[:key]` still works. You can also use accessor-style access:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
result.data[:user] # still works
|
|
53
|
+
result.data.user # also works (new)
|
|
54
|
+
result.data.user.email # nested access (new)
|
|
55
|
+
```
|
|
6
56
|
|
|
7
57
|
## Incremental Adoption
|
|
8
58
|
|
|
@@ -42,6 +42,13 @@ class ProcessPayment::Service < Servus::Base
|
|
|
42
42
|
transaction_id: { type: "string", example: "txn_abc123" },
|
|
43
43
|
status: { type: "string", example: "approved" }
|
|
44
44
|
}
|
|
45
|
+
},
|
|
46
|
+
failure: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
reason: { type: "string", example: "card_declined" },
|
|
50
|
+
decline_code: { type: "string", example: "insufficient_funds" }
|
|
51
|
+
}
|
|
45
52
|
}
|
|
46
53
|
)
|
|
47
54
|
end
|
|
@@ -64,6 +71,15 @@ RSpec.describe ProcessPayment::Service do
|
|
|
64
71
|
)
|
|
65
72
|
end
|
|
66
73
|
|
|
74
|
+
it "returns structured failure data on decline" do
|
|
75
|
+
args = servus_arguments_example(ProcessPayment::Service, amount: 999_999)
|
|
76
|
+
result = ProcessPayment::Service.call(**args)
|
|
77
|
+
|
|
78
|
+
expected = servus_failure_example(ProcessPayment::Service)
|
|
79
|
+
expect(result).to be_failure
|
|
80
|
+
expect(result.data.keys).to match_array(expected.data.keys)
|
|
81
|
+
end
|
|
82
|
+
|
|
67
83
|
it "handles different currencies" do
|
|
68
84
|
%w[USD EUR GBP].each do |currency|
|
|
69
85
|
result = ProcessPayment::Service.call(
|
|
@@ -91,7 +107,8 @@ args = servus_arguments_example(
|
|
|
91
107
|
### Available Helpers
|
|
92
108
|
|
|
93
109
|
- `servus_arguments_example(ServiceClass, **overrides)` - Returns hash of argument examples
|
|
94
|
-
- `servus_result_example(ServiceClass, **overrides)` - Returns Response
|
|
110
|
+
- `servus_result_example(ServiceClass, **overrides)` - Returns successful Response with result examples
|
|
111
|
+
- `servus_failure_example(ServiceClass, **overrides)` - Returns failure Response with failure schema examples
|
|
95
112
|
|
|
96
113
|
## Basic Testing Pattern
|
|
97
114
|
|
data/lib/servus/base.rb
CHANGED
|
@@ -88,6 +88,8 @@ module Servus
|
|
|
88
88
|
# The failure is logged automatically and returns a response containing the error.
|
|
89
89
|
#
|
|
90
90
|
# @param message [String, nil] custom error message (uses error type's default if nil)
|
|
91
|
+
# @param data [Object, nil] optional structured data to attach to the failure response.
|
|
92
|
+
# When a +failure+ schema is defined, this data will be validated against it.
|
|
91
93
|
# @param type [Class] error class to instantiate (must inherit from ServiceError)
|
|
92
94
|
# @return [Servus::Support::Response] response with success: false and the error
|
|
93
95
|
#
|
|
@@ -109,12 +111,17 @@ module Servus
|
|
|
109
111
|
# # Uses "Not found" as the message
|
|
110
112
|
# end
|
|
111
113
|
#
|
|
114
|
+
# @example Attaching structured data to a failure
|
|
115
|
+
# def call
|
|
116
|
+
# return failure("Approval required", data: { requires_human_approval: true })
|
|
117
|
+
# end
|
|
118
|
+
#
|
|
112
119
|
# @see #success
|
|
113
120
|
# @see #error!
|
|
114
121
|
# @see Servus::Support::Errors
|
|
115
|
-
def failure(message = nil, type: Servus::Support::Errors::ServiceError)
|
|
122
|
+
def failure(message = nil, data: nil, type: Servus::Support::Errors::ServiceError)
|
|
116
123
|
error = type.new(message)
|
|
117
|
-
Response.new(false,
|
|
124
|
+
Response.new(false, data, error)
|
|
118
125
|
end
|
|
119
126
|
|
|
120
127
|
# Logs an error and raises an exception, halting service execution.
|
|
@@ -207,15 +214,16 @@ module Servus
|
|
|
207
214
|
end
|
|
208
215
|
# rubocop:enable Metrics/MethodLength
|
|
209
216
|
|
|
210
|
-
# Defines schema validation rules for the service's arguments and/or
|
|
217
|
+
# Defines schema validation rules for the service's arguments, result, and/or failure data.
|
|
211
218
|
#
|
|
212
219
|
# This method provides a clean DSL for specifying JSON schemas that will be used
|
|
213
220
|
# to validate service inputs and outputs. Schemas defined via this method take
|
|
214
|
-
# precedence over ARGUMENTS_SCHEMA and
|
|
215
|
-
# version will deprecate those constants in favor of this DSL.
|
|
221
|
+
# precedence over ARGUMENTS_SCHEMA, RESULT_SCHEMA, and FAILURE_SCHEMA constants.
|
|
222
|
+
# The next major version will deprecate those constants in favor of this DSL.
|
|
216
223
|
#
|
|
217
224
|
# @param arguments [Hash, nil] JSON schema for validating service arguments
|
|
218
225
|
# @param result [Hash, nil] JSON schema for validating service result data
|
|
226
|
+
# @param failure [Hash, nil] JSON schema for validating failure response data
|
|
219
227
|
# @return [void]
|
|
220
228
|
#
|
|
221
229
|
# @example Defining both arguments and result schemas
|
|
@@ -245,9 +253,10 @@ module Servus
|
|
|
245
253
|
# end
|
|
246
254
|
#
|
|
247
255
|
# @see Servus::Support::Validator
|
|
248
|
-
def schema(arguments: nil, result: nil)
|
|
256
|
+
def schema(arguments: nil, result: nil, failure: nil)
|
|
249
257
|
@arguments_schema = arguments.with_indifferent_access if arguments
|
|
250
258
|
@result_schema = result.with_indifferent_access if result
|
|
259
|
+
@failure_schema = failure.with_indifferent_access if failure
|
|
251
260
|
end
|
|
252
261
|
|
|
253
262
|
# Returns the arguments schema defined via the schema DSL method.
|
|
@@ -262,6 +271,12 @@ module Servus
|
|
|
262
271
|
# @api private
|
|
263
272
|
attr_reader :result_schema
|
|
264
273
|
|
|
274
|
+
# Returns the failure schema defined via the schema DSL method.
|
|
275
|
+
#
|
|
276
|
+
# @return [Hash, nil] the failure schema or nil if not defined
|
|
277
|
+
# @api private
|
|
278
|
+
attr_reader :failure_schema
|
|
279
|
+
|
|
265
280
|
# Executes pre-call hooks including logging and argument validation.
|
|
266
281
|
#
|
|
267
282
|
# This method is automatically called before service execution and handles:
|