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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f20bb1c0b41657377b94a29aef1915dc7a0c4c65442d21b9f6a60c75537dd8a
4
- data.tar.gz: 9273a9d2e3efa50c3ce2d80935e7538a1fa89e0cc3195acfde974621f7960277
3
+ metadata.gz: 58e6098b9ea316c670b5b8aa6f61828d961cc3c8ad6e72357d71c442dfe75151
4
+ data.tar.gz: 215f0f790ad36575b0b9de3f956cf585f19051c16d5250ada1ae152a969dd1d7
5
5
  SHA512:
6
- metadata.gz: e2e4ec7e3390784c35acbe238b3d809c4e07859c39ccd159a1618daf3439d04d7bb4eb3c847ea30e1a3b94a1d4dfac2508601ce263160ddee1d7e0a9e20ed13b
7
- data.tar.gz: 4f135793c078f9230436a6ff3f9508847469a198dfa818afadcbace146b7b85fdb78844a864538b96bded6cb3eed910002c0fdc8b6802ad4cae3189e06349a54
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
- - Added: Test helpers (`servus_arguments_example` and `servus_result_example`) to extract example values from schemas for testing
105
- - Added: YARD documentation configuration with README homepage and markdown file support
106
- - Added: Added `schema` DSL method for cleaner schema definition. Supports `schema arguments: {...}, result: {...}` syntax. Fully backwards compatible with existing `ARGUMENTS_SCHEMA` and `RESULT_SCHEMA` constants.
107
- - Added: Added support from blocks on `rescue_from` to override default failure handler.
108
- - Fixed: YARD link resolution warnings in documentation
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
- - Added: Added `call_async` method to `Servus::Base` to enqueue a job for calling the service asynchronously
112
- - Added: Added `Async::Job` to handle async enqueing with support for ActiveJob set options
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
- - Added: Added `rescue_from` method to `Servus::Base` to rescue from standard errors and use custom error types.
117
- - Added: Added `run_service` and `render_service_object_error` helpers to `Servus::Helpers::ControllerHelpers`.
118
- - Fixed: All rubocop warnings.
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
 
@@ -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 # Hash or object returned by success()
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 # ServiceError instance
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 object with result examples
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, nil, error)
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 result.
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 RESULT_SCHEMA constants. The next major
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
@@ -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 RESULT_SCHEMA.
61
+ # Validates service result data against the appropriate schema.
62
62
  #
63
- # Checks the result.data against either an inline RESULT_SCHEMA constant or
64
- # a file-based schema at app/schemas/services/namespace/result.json.
65
- # Only validates successful responses; failures are skipped.
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
- return result unless result.success?
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
- error_message = "Invalid result structure from #{service_class.name}: #{validation_errors.join(', ')}"
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 "result")
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 = if type == 'arguments'
145
- service_class.arguments_schema
146
- else
147
- service_class.result_schema
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Servus
4
- VERSION = '0.2.1'
4
+ VERSION = '0.3.0'
5
5
  end
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.2.1
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: 3.6.7
254
+ rubygems_version: 4.0.6
249
255
  specification_version: 4
250
256
  summary: A gem for managing service objects.
251
257
  test_files: []