servus 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f63266b1efb1e782745c67d24957ce1126b1031592667bd78fe80f5ed2c93ac7
4
- data.tar.gz: 64d263185dc9214707aafc7eaaf740343e5c24311eec49db6bbccff25fa413a9
3
+ metadata.gz: 58e6098b9ea316c670b5b8aa6f61828d961cc3c8ad6e72357d71c442dfe75151
4
+ data.tar.gz: 215f0f790ad36575b0b9de3f956cf585f19051c16d5250ada1ae152a969dd1d7
5
5
  SHA512:
6
- metadata.gz: a6d3ff24ec58b33b926acca83447ede76c1feb54daab1ade3b4a744f66e70a87785b68d6e235934b6b20f6477e7518441486bb6d405f34cd8bfb2334c095122e
7
- data.tar.gz: 5f562d3cac4a258387fab0936d8fecb41e9e42803556014d91c5802f0b27f6b5cd2a085f76b4d0d486c609f2589c286d5fe13fbd4be34270f4ecc104d2b478a1
6
+ metadata.gz: 48142cd74cbd766846ccd9d8bf4a71a8d67136cbd60bdc948ddda6a0fa99f7e6072351b6ff98958500a6d84e49d1cb7d122e8e435efeb8b1384d39a4be9932ad
7
+ data.tar.gz: 13899ee4220e2e25b888eae9b6675a77780770628717bc11a2abda9bdd82e49b0798d70c954f91e5649a3bc281d9b3ad0e50dce41374f3c2ebc42125d7321848
data/CHANGELOG.md CHANGED
@@ -1,3 +1,50 @@
1
+ ## [0.3.0] - 2026-04-03
2
+
3
+ ### Breaking Changes
4
+
5
+ - **Failure responses can now carry data**: `failure()` accepts an optional `data:` kwarg. Previously,
6
+ `result.data` was guaranteed to be `nil` on failure. Code that checks `result.data` for truthiness
7
+ to determine success/failure must switch to `result.success?` or `result.failure?`.
8
+ See the [Migration Guide](docs/guides/2_migration_guide.md#migrating-to-030) for details.
9
+ - **`Response#with_data` removed**: Replaced by the `data:` kwarg on `failure()`. The `with_data` method
10
+ allowed arbitrary mutation of responses after creation, bypassing schema validation.
11
+
12
+ ### Added
13
+
14
+ - **`lazily` resolver DSL**: Declare lazy record resolvers on services with `lazily :user, finds: User`.
15
+ Accepts either an ID or an already-loaded instance — resolves on first access, memoizes the result.
16
+ Supports custom columns (`by: :uuid`), array input (via `.where`), and dry-initializer compatibility.
17
+ Loaded as an extension via Railtie when ActiveRecord is present.
18
+ - **Failure data support**: `failure()` accepts an optional `data:` keyword argument for attaching
19
+ structured data to failure responses (e.g., `failure("Declined", data: { reason: "insufficient_funds" })`).
20
+ Defaults to `nil` for backwards compatibility with services that don't use it.
21
+ - **Failure schema validation**: Define a `failure` schema via the `schema` DSL, `FAILURE_SCHEMA` constant,
22
+ or `failure.json` file. When present, failure response data is validated against it — just like success
23
+ results are validated against `result` schemas.
24
+ - **`servus_failure_example` test helper**: Extracts example values from a service's `failure` schema,
25
+ returning a failure `Response` for use in tests.
26
+ - **`failure?` predicate on Response**: Complement to `success?` for cleaner conditional handling.
27
+ - **`DataObject` wrapper for response data**: Hash data returned by services is wrapped in a read-only
28
+ `DataObject` that supports accessor-style access (`result.data.user.email`) alongside bracket access
29
+ (`result.data[:user]`). Nested Hashes and Hashes inside Arrays are recursively wrapped. Non-Hash values
30
+ (models, nil) pass through unwrapped.
31
+
32
+ ## [0.2.1] - 2025-12-20
33
+
34
+ ### Added
35
+
36
+ - **EventHandler Scheduling Options**: Extended the `invoke` DSL to support ActiveJob scheduling options
37
+ - `:wait` - delay execution (e.g., `5.minutes`)
38
+ - `:wait_until` - schedule for specific time
39
+ - `:priority` - job priority
40
+ - `:job_options` - additional ActiveJob options
41
+ - Options are passed through to `call_async`, enabling delayed and scheduled event handling
42
+
43
+ - **Custom HTTP Error Classes**: Added granular error classes for HTTP status handling
44
+ - Error classes for common HTTP statuses (400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504)
45
+ - Each error class has appropriate `http_status` and default `code`/`message`
46
+ - Enables more precise error handling and cleaner rescue blocks
47
+
1
48
  ## [0.2.0] - 2025-12-16
2
49
 
3
50
  ### Added
@@ -85,21 +132,37 @@
85
132
  - Enhanced Railtie to auto-load event handlers and clear the event bus on reload in development
86
133
 
87
134
  ## [0.1.4] - 2025-11-21
88
- - Added: Test helpers (`servus_arguments_example` and `servus_result_example`) to extract example values from schemas for testing
89
- - Added: YARD documentation configuration with README homepage and markdown file support
90
- - 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.
91
- - Added: Added support from blocks on `rescue_from` to override default failure handler.
92
- - 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
93
148
 
94
149
  ## [0.1.3] - 2025-10-10
95
- - Added: Added `call_async` method to `Servus::Base` to enqueue a job for calling the service asynchronously
96
- - 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
97
155
 
98
156
  ## [0.1.1] - 2025-08-20
99
157
 
100
- - Added: Added `rescue_from` method to `Servus::Base` to rescue from standard errors and use custom error types.
101
- - Added: Added `run_service` and `render_service_object_error` helpers to `Servus::Helpers::ControllerHelpers`.
102
- - 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
103
166
 
104
167
  ## [0.1.0] - 2025-04-28
105
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: