servus 0.2.1 → 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 +57 -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/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/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,34 @@
|
|
|
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
|
+
|
|
1
32
|
## [0.2.1] - 2025-12-20
|
|
2
33
|
|
|
3
34
|
### Added
|
|
@@ -101,21 +132,37 @@
|
|
|
101
132
|
- Enhanced Railtie to auto-load event handlers and clear the event bus on reload in development
|
|
102
133
|
|
|
103
134
|
## [0.1.4] - 2025-11-21
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
-
|
|
108
|
-
|
|
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
|
|
109
148
|
|
|
110
149
|
## [0.1.3] - 2025-10-10
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
113
155
|
|
|
114
156
|
## [0.1.1] - 2025-08-20
|
|
115
157
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
-
|
|
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
|
|
119
166
|
|
|
120
167
|
## [0.1.0] - 2025-04-28
|
|
121
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:
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Extensions
|
|
5
|
+
module Lazily
|
|
6
|
+
# Provides lazy record resolution for service inputs.
|
|
7
|
+
#
|
|
8
|
+
# This module extends {Servus::Base} with the {#lazily} class method, enabling
|
|
9
|
+
# services to accept either a record ID or an already-loaded record instance.
|
|
10
|
+
# Resolution happens lazily on first access and is memoized.
|
|
11
|
+
#
|
|
12
|
+
# @see Lazily#lazily
|
|
13
|
+
module Call
|
|
14
|
+
# Declares a lazy record resolver for a service input.
|
|
15
|
+
#
|
|
16
|
+
# Defines an accessor method that lazily resolves the input value to a record.
|
|
17
|
+
# If the value is already an instance of the target class, it is returned directly.
|
|
18
|
+
# If the value is an ID (or other lookup value), it is resolved via the target
|
|
19
|
+
# class's +.find+ or +.find_by!+ method. Arrays are resolved via +.where+.
|
|
20
|
+
#
|
|
21
|
+
# The resolved record is written back to the instance variable, so subsequent
|
|
22
|
+
# calls return the same object without re-querying.
|
|
23
|
+
#
|
|
24
|
+
# @param name [Symbol] the param/ivar name, also becomes the accessor method
|
|
25
|
+
# @param finds [Class] the model class to resolve against (e.g., +User+, +Account+)
|
|
26
|
+
# @param by [Symbol] the lookup column (default: +:id+). When +:id+, uses +.find+.
|
|
27
|
+
# Otherwise uses +.find_by!(column: value)+.
|
|
28
|
+
# @return [void]
|
|
29
|
+
#
|
|
30
|
+
# @example Basic usage with .find
|
|
31
|
+
# lazily :user, finds: User
|
|
32
|
+
# # user: 123 → User.find(123)
|
|
33
|
+
# # user: user_inst → returns user_inst directly
|
|
34
|
+
#
|
|
35
|
+
# @example Custom column lookup
|
|
36
|
+
# lazily :account, finds: Account, by: :uuid
|
|
37
|
+
# # account: "abc-def" → Account.find_by!(uuid: "abc-def")
|
|
38
|
+
#
|
|
39
|
+
# @example Array input
|
|
40
|
+
# lazily :users, finds: User
|
|
41
|
+
# # users: [1, 2, 3] → User.where(id: [1, 2, 3])
|
|
42
|
+
#
|
|
43
|
+
# @note Only available when ActiveRecord is loaded (via Railtie)
|
|
44
|
+
# @see Servus::Base.call
|
|
45
|
+
def lazily(name, finds:, by: :id)
|
|
46
|
+
(@lazy_resolvers ||= {})[name] = { klass: finds, by: by }
|
|
47
|
+
define_resolver_method(name, finds, by)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the hash of registered lazy resolvers for this service class.
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash{Symbol => Hash}] resolver configurations keyed by name
|
|
53
|
+
# @api private
|
|
54
|
+
def lazy_resolvers
|
|
55
|
+
@lazy_resolvers || {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Defines a lazy accessor method on a prepended module.
|
|
61
|
+
#
|
|
62
|
+
# @param name [Symbol] the method/ivar name
|
|
63
|
+
# @param klass [Class] the target model class
|
|
64
|
+
# @param by [Symbol] the lookup column
|
|
65
|
+
# @api private
|
|
66
|
+
def define_resolver_method(name, klass, by)
|
|
67
|
+
mod = (@_resolver_module ||= Module.new)
|
|
68
|
+
prepend(mod) unless ancestors.include?(mod)
|
|
69
|
+
|
|
70
|
+
mod.define_method(name) do
|
|
71
|
+
@_lazily_resolved ||= {}
|
|
72
|
+
return instance_variable_get(:"@#{name}") if @_lazily_resolved[name]
|
|
73
|
+
|
|
74
|
+
resolved = Resolver.call(instance_variable_get(:"@#{name}"), klass: klass, by: by, name: name)
|
|
75
|
+
@_lazily_resolved[name] = true
|
|
76
|
+
instance_variable_set(:"@#{name}", resolved)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Extensions
|
|
5
|
+
module Lazily
|
|
6
|
+
# Error classes for lazy record resolution.
|
|
7
|
+
#
|
|
8
|
+
# These errors are raised when lazy resolution fails, such as when
|
|
9
|
+
# a required record reference is nil.
|
|
10
|
+
module Errors
|
|
11
|
+
# Base error class for all lazily extension errors.
|
|
12
|
+
#
|
|
13
|
+
# All lazy resolution errors inherit from this class for easy rescue handling.
|
|
14
|
+
class LazilyError < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Raised when a lazily-resolved record reference is nil.
|
|
17
|
+
#
|
|
18
|
+
# This occurs when a service declares +lazily :user, finds: User+ but
|
|
19
|
+
# receives +user: nil+. A nil reference is always a bug at the call site.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# class MyService < Servus::Base
|
|
23
|
+
# lazily :user, finds: User
|
|
24
|
+
#
|
|
25
|
+
# def initialize(user:)
|
|
26
|
+
# @user = user
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# def call
|
|
30
|
+
# user # => raises NotFoundError if @user is nil
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
class NotFoundError < LazilyError; end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Extensions
|
|
5
|
+
# Lazy record resolution extensions for Servus services.
|
|
6
|
+
#
|
|
7
|
+
# This module provides the infrastructure for lazily resolving record
|
|
8
|
+
# references (IDs or instances) in service inputs. When loaded, it extends
|
|
9
|
+
# {Servus::Base} with the {Call#lazily} class method.
|
|
10
|
+
#
|
|
11
|
+
# @see Servus::Extensions::Lazily::Call
|
|
12
|
+
module Lazily
|
|
13
|
+
require 'servus/extensions/lazily/errors'
|
|
14
|
+
require 'servus/extensions/lazily/resolver'
|
|
15
|
+
require 'servus/extensions/lazily/call'
|
|
16
|
+
|
|
17
|
+
# Extension module for lazily functionality.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
module Ext; end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Extensions
|
|
5
|
+
module Lazily
|
|
6
|
+
# Performs the actual record resolution for a lazily-declared input.
|
|
7
|
+
#
|
|
8
|
+
# Handles the decision logic: is the raw value already an instance,
|
|
9
|
+
# nil, an array, or a lookup value? Delegates to the appropriate
|
|
10
|
+
# finder method on the target class.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class Resolver
|
|
14
|
+
# Resolves a raw value to a record (or collection of records).
|
|
15
|
+
#
|
|
16
|
+
# @param raw [Object] the raw input value (ID, instance, Array, or nil)
|
|
17
|
+
# @param klass [Class] the target model class
|
|
18
|
+
# @param by [Symbol] the lookup column
|
|
19
|
+
# @param name [Symbol] the param name (for error messages)
|
|
20
|
+
# @return [Object] the resolved record or collection
|
|
21
|
+
# @raise [Errors::NotFoundError] if raw is nil
|
|
22
|
+
def self.call(raw, klass:, by:, name:)
|
|
23
|
+
return raw if raw.is_a?(klass)
|
|
24
|
+
raise Errors::NotFoundError, "Couldn't find #{klass} (#{name} was nil)" if raw.nil?
|
|
25
|
+
return klass.where(by => raw) if raw.is_a?(Array)
|
|
26
|
+
|
|
27
|
+
by == :id ? klass.find(raw) : klass.find_by!(by => raw)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/servus/railtie.rb
CHANGED
|
@@ -14,11 +14,17 @@ module Servus
|
|
|
14
14
|
initializer 'servus.job_async' do
|
|
15
15
|
ActiveSupport.on_load(:active_job) do
|
|
16
16
|
require 'servus/extensions/async/ext'
|
|
17
|
-
# Extend the base service with the async call method
|
|
18
17
|
Servus::Base.extend Servus::Extensions::Async::Call
|
|
19
18
|
end
|
|
20
19
|
end
|
|
21
20
|
|
|
21
|
+
initializer 'servus.lazily' do
|
|
22
|
+
ActiveSupport.on_load(:active_record) do
|
|
23
|
+
require 'servus/extensions/lazily/ext'
|
|
24
|
+
Servus::Base.extend Servus::Extensions::Lazily::Call
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
# Load guards and event handlers, clear caches on reload
|
|
23
29
|
config.to_prepare do
|
|
24
30
|
# Load custom guards from guards_dir
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'delegate'
|
|
4
|
+
|
|
5
|
+
module Servus
|
|
6
|
+
module Support
|
|
7
|
+
# A read-only wrapper around Hash data that provides accessor-style access.
|
|
8
|
+
#
|
|
9
|
+
# When service results contain Hash data, +DataObject+ wraps it so keys can be
|
|
10
|
+
# accessed as methods in addition to the standard bracket syntax. Nested Hashes
|
|
11
|
+
# are recursively wrapped, enabling chained access like +data.user.address.city+.
|
|
12
|
+
#
|
|
13
|
+
# Non-Hash values (nil, String, Integer, Array, model instances) pass through
|
|
14
|
+
# unwrapped. This means +data.user+ returns the original object when +user+ is
|
|
15
|
+
# an ActiveRecord model, allowing natural method chaining (+data.user.email+).
|
|
16
|
+
#
|
|
17
|
+
# +DataObject+ inherits from +SimpleDelegator+, so all standard Hash methods
|
|
18
|
+
# (+[]+, +keys+, +each+, +as_json+, +==+, etc.) are delegated transparently.
|
|
19
|
+
#
|
|
20
|
+
# @example Accessor-style access
|
|
21
|
+
# data = DataObject.wrap({ user: { email: "alice@example.com" } })
|
|
22
|
+
# data.user.email # => "alice@example.com"
|
|
23
|
+
# data[:user] # => { email: "alice@example.com" } (plain Hash)
|
|
24
|
+
#
|
|
25
|
+
# @example Mixed values
|
|
26
|
+
# data = DataObject.wrap({ user: user_model, metadata: { source: "api" } })
|
|
27
|
+
# data.user.email # => delegates to model's #email method
|
|
28
|
+
# data.metadata.source # => "api" (wrapped Hash accessor)
|
|
29
|
+
#
|
|
30
|
+
# @see Servus::Support::Response
|
|
31
|
+
class DataObject < SimpleDelegator
|
|
32
|
+
# Wraps a value in a DataObject if it is a Hash.
|
|
33
|
+
#
|
|
34
|
+
# Non-Hash values are returned unchanged. This is the preferred way to
|
|
35
|
+
# create DataObject instances, as it handles nil and non-Hash types safely.
|
|
36
|
+
#
|
|
37
|
+
# @param data [Object] the value to potentially wrap
|
|
38
|
+
# @return [DataObject] if data is a Hash
|
|
39
|
+
# @return [Array] with Hash elements wrapped if data is an Array
|
|
40
|
+
# @return [Object] the original value otherwise
|
|
41
|
+
def self.wrap(data)
|
|
42
|
+
case data
|
|
43
|
+
when Hash then new(data)
|
|
44
|
+
when Array then data.map { |item| wrap(item) }
|
|
45
|
+
else data
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Provides accessor-style access to Hash keys.
|
|
52
|
+
#
|
|
53
|
+
# Only zero-argument, no-block calls trigger key lookup. Methods with
|
|
54
|
+
# arguments (e.g., +fetch+, +dig+) delegate to Hash normally.
|
|
55
|
+
# When the value is a Hash, it is recursively wrapped in a DataObject.
|
|
56
|
+
#
|
|
57
|
+
# @param method_name [Symbol] the method name to look up as a key
|
|
58
|
+
# @return [Object] the value for the key, wrapped if it is a Hash
|
|
59
|
+
# @raise [NoMethodError] if the key does not exist
|
|
60
|
+
def method_missing(method_name, *args, &block)
|
|
61
|
+
return super if args.any? || block
|
|
62
|
+
|
|
63
|
+
hash = __getobj__
|
|
64
|
+
if hash.key?(method_name.to_sym)
|
|
65
|
+
self.class.wrap(hash[method_name.to_sym])
|
|
66
|
+
elsif hash.key?(method_name.to_s)
|
|
67
|
+
self.class.wrap(hash[method_name.to_s])
|
|
68
|
+
else
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
75
|
+
hash = __getobj__
|
|
76
|
+
hash.key?(method_name.to_sym) || hash.key?(method_name.to_s) || super
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -51,7 +51,7 @@ module Servus
|
|
|
51
51
|
# @api private
|
|
52
52
|
def initialize(success, data, error)
|
|
53
53
|
@success = success
|
|
54
|
-
@data = data
|
|
54
|
+
@data = DataObject.wrap(data)
|
|
55
55
|
@error = error
|
|
56
56
|
end
|
|
57
57
|
|
|
@@ -69,6 +69,17 @@ module Servus
|
|
|
69
69
|
def success?
|
|
70
70
|
@success
|
|
71
71
|
end
|
|
72
|
+
|
|
73
|
+
# Checks if the service execution failed.
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] true if the service failed, false if it succeeded
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# result = MyService.call(params)
|
|
79
|
+
# return render_error(result.error.message) if result.failure?
|
|
80
|
+
def failure?
|
|
81
|
+
!@success
|
|
82
|
+
end
|
|
72
83
|
end
|
|
73
84
|
end
|
|
74
85
|
end
|
|
@@ -58,11 +58,11 @@ module Servus
|
|
|
58
58
|
true
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
# Validates service result data against the
|
|
61
|
+
# Validates service result data against the appropriate schema.
|
|
62
62
|
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
63
|
+
# For successful responses, validates against the +result+ schema.
|
|
64
|
+
# For failure responses with data, validates against the +failure+ schema.
|
|
65
|
+
# Failure responses without data are skipped.
|
|
66
66
|
#
|
|
67
67
|
# @param service_class [Class] the service class being validated
|
|
68
68
|
# @param result [Servus::Support::Response] the response object to validate
|
|
@@ -74,16 +74,15 @@ module Servus
|
|
|
74
74
|
#
|
|
75
75
|
# @api private
|
|
76
76
|
def self.validate_result!(service_class, result)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
schema = load_schema(service_class, 'result')
|
|
80
|
-
return result unless schema # Skip validation if no schema exists
|
|
77
|
+
schema = result_schema_for(service_class, result)
|
|
78
|
+
return result unless schema
|
|
81
79
|
|
|
82
80
|
serialized_result = result.data.as_json
|
|
83
81
|
validation_errors = JSON::Validator.fully_validate(schema, serialized_result)
|
|
84
82
|
|
|
85
83
|
if validation_errors.any?
|
|
86
|
-
|
|
84
|
+
schema_type = result.success? ? 'result' : 'failure'
|
|
85
|
+
error_message = "Invalid #{schema_type} structure from #{service_class.name}: #{validation_errors.join(', ')}"
|
|
87
86
|
raise Servus::Base::ValidationError, error_message
|
|
88
87
|
end
|
|
89
88
|
|
|
@@ -127,7 +126,7 @@ module Servus
|
|
|
127
126
|
# Schemas are cached after first load for performance.
|
|
128
127
|
#
|
|
129
128
|
# @param service_class [Class] the service class
|
|
130
|
-
# @param type [String] schema type ("arguments" or "
|
|
129
|
+
# @param type [String] schema type ("arguments", "result", or "failure")
|
|
131
130
|
# @return [Hash, nil] the schema hash, or nil if no schema found
|
|
132
131
|
#
|
|
133
132
|
# @api private
|
|
@@ -141,10 +140,10 @@ module Servus
|
|
|
141
140
|
return @schema_cache[schema_path] if @schema_cache.key?(schema_path)
|
|
142
141
|
|
|
143
142
|
# Check for DSL-defined schema first
|
|
144
|
-
dsl_schema =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
dsl_schema = case type
|
|
144
|
+
when 'arguments' then service_class.arguments_schema
|
|
145
|
+
when 'result' then service_class.result_schema
|
|
146
|
+
when 'failure' then service_class.failure_schema
|
|
148
147
|
end
|
|
149
148
|
|
|
150
149
|
inline_schema_constant_name = "#{service_class}::#{type.upcase}_SCHEMA"
|
|
@@ -180,6 +179,21 @@ module Servus
|
|
|
180
179
|
@schema_cache
|
|
181
180
|
end
|
|
182
181
|
|
|
182
|
+
# Resolves the appropriate schema for a result based on its success/failure state.
|
|
183
|
+
#
|
|
184
|
+
# @param service_class [Class] the service class
|
|
185
|
+
# @param result [Servus::Support::Response] the response to resolve schema for
|
|
186
|
+
# @return [Hash, nil] the schema, or nil if none applies
|
|
187
|
+
#
|
|
188
|
+
# @api private
|
|
189
|
+
def self.result_schema_for(service_class, result)
|
|
190
|
+
if result.success?
|
|
191
|
+
load_schema(service_class, 'result')
|
|
192
|
+
elsif result.data
|
|
193
|
+
load_schema(service_class, 'failure')
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
183
197
|
# Fetches schema from DSL, inline constant, or file.
|
|
184
198
|
#
|
|
185
199
|
# Implements the schema resolution precedence:
|
|
@@ -116,6 +116,28 @@ module Servus
|
|
|
116
116
|
Servus::Support::Response.new(true, example, nil)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# Extracts example failure data values from a service's schema.
|
|
120
|
+
#
|
|
121
|
+
# Looks for `example` or `examples` keywords in the service's failure schema
|
|
122
|
+
# and returns them wrapped in a failure Response. Useful for validating failure
|
|
123
|
+
# response structure in tests.
|
|
124
|
+
#
|
|
125
|
+
# @param service_class [Class] The service class to extract examples from
|
|
126
|
+
# @param overrides [Hash] Optional values to override the schema examples
|
|
127
|
+
# @return [Servus::Support::Response] Failure response object with example data
|
|
128
|
+
#
|
|
129
|
+
# @example Basic usage
|
|
130
|
+
# expected = servus_failure_example(ProcessPayment::Service)
|
|
131
|
+
# # => Servus::Support::Response with failure? == true, data:
|
|
132
|
+
# # { reason: 'card_declined', decline_code: 'insufficient_funds' }
|
|
133
|
+
#
|
|
134
|
+
# @note Override keys can be strings or symbols; they'll be converted to symbols
|
|
135
|
+
# @note Returns empty hash if service has no failure schema defined
|
|
136
|
+
def servus_failure_example(service_class, overrides = {})
|
|
137
|
+
example = extract_example_from(service_class, :failure, overrides)
|
|
138
|
+
Servus::Support::Response.new(false, example, Servus::Support::Errors::ServiceError.new)
|
|
139
|
+
end
|
|
140
|
+
|
|
119
141
|
private
|
|
120
142
|
|
|
121
143
|
# Helper method to extract and merge examples from schema
|
data/lib/servus/version.rb
CHANGED
data/lib/servus.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative 'servus/config'
|
|
|
20
20
|
|
|
21
21
|
# Support
|
|
22
22
|
require_relative 'servus/support/logger'
|
|
23
|
+
require_relative 'servus/support/data_object'
|
|
23
24
|
require_relative 'servus/support/response'
|
|
24
25
|
require_relative 'servus/support/validator'
|
|
25
26
|
require_relative 'servus/support/errors'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: servus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Scholl
|
|
@@ -27,14 +27,14 @@ dependencies:
|
|
|
27
27
|
name: activesupport
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
|
-
- - "
|
|
30
|
+
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
32
|
version: '8.0'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
|
-
- - "
|
|
37
|
+
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '8.0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
@@ -55,14 +55,14 @@ dependencies:
|
|
|
55
55
|
name: actionpack
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
|
-
- - "
|
|
58
|
+
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
60
|
version: '8.0'
|
|
61
61
|
type: :development
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
|
-
- - "
|
|
65
|
+
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '8.0'
|
|
68
68
|
description: Servus is a Ruby gem that provides a structured way to create and manage
|
|
@@ -97,6 +97,7 @@ files:
|
|
|
97
97
|
- docs/features/4_logging.md
|
|
98
98
|
- docs/features/5_event_bus.md
|
|
99
99
|
- docs/features/6_guards.md
|
|
100
|
+
- docs/features/7_lazy_resolvers.md
|
|
100
101
|
- docs/features/guards_naming_convention.md
|
|
101
102
|
- docs/guides/1_common_patterns.md
|
|
102
103
|
- docs/guides/2_migration_guide.md
|
|
@@ -203,6 +204,10 @@ files:
|
|
|
203
204
|
- lib/servus/extensions/async/errors.rb
|
|
204
205
|
- lib/servus/extensions/async/ext.rb
|
|
205
206
|
- lib/servus/extensions/async/job.rb
|
|
207
|
+
- lib/servus/extensions/lazily/call.rb
|
|
208
|
+
- lib/servus/extensions/lazily/errors.rb
|
|
209
|
+
- lib/servus/extensions/lazily/ext.rb
|
|
210
|
+
- lib/servus/extensions/lazily/resolver.rb
|
|
206
211
|
- lib/servus/guard.rb
|
|
207
212
|
- lib/servus/guards.rb
|
|
208
213
|
- lib/servus/guards/falsey_guard.rb
|
|
@@ -211,6 +216,7 @@ files:
|
|
|
211
216
|
- lib/servus/guards/truthy_guard.rb
|
|
212
217
|
- lib/servus/helpers/controller_helpers.rb
|
|
213
218
|
- lib/servus/railtie.rb
|
|
219
|
+
- lib/servus/support/data_object.rb
|
|
214
220
|
- lib/servus/support/errors.rb
|
|
215
221
|
- lib/servus/support/logger.rb
|
|
216
222
|
- lib/servus/support/message_resolver.rb
|
|
@@ -245,7 +251,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
245
251
|
- !ruby/object:Gem::Version
|
|
246
252
|
version: '0'
|
|
247
253
|
requirements: []
|
|
248
|
-
rubygems_version:
|
|
254
|
+
rubygems_version: 4.0.6
|
|
249
255
|
specification_version: 4
|
|
250
256
|
summary: A gem for managing service objects.
|
|
251
257
|
test_files: []
|