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.
@@ -54,6 +54,39 @@ module Servus
54
54
  def api_error = { code: http_status, message: message }
55
55
  end
56
56
 
57
+ # Guard validation failure with custom code.
58
+ #
59
+ # Guards define their own error code and HTTP status via the DSL.
60
+ #
61
+ # @example
62
+ # GuardError.new("Amount must be positive", code: 'invalid_amount', http_status: 422)
63
+ class GuardError < ServiceError
64
+ DEFAULT_MESSAGE = 'Guard validation failed'
65
+
66
+ # @return [String] application-specific error code
67
+ attr_reader :code
68
+
69
+ # @return [Symbol, Integer] HTTP status code
70
+ attr_reader :http_status
71
+
72
+ # Creates a new guard error with metadata.
73
+ #
74
+ # @param message [String, nil] error message
75
+ # @param code [String] error code for API responses (default: 'guard_failed')
76
+ # @param http_status [Symbol, Integer] HTTP status (default: :unprocessable_entity)
77
+ def initialize(message = nil, code: 'guard_failed', http_status: :unprocessable_entity)
78
+ super(message)
79
+ @code = code
80
+ @http_status = http_status
81
+ end
82
+
83
+ def api_error = { code: code, message: message }
84
+ end
85
+
86
+ # --------------------------------------------------------
87
+ # Standard HTTP error classes
88
+ # --------------------------------------------------------
89
+
57
90
  # 400 Bad Request - malformed or invalid request data.
58
91
  class BadRequestError < ServiceError
59
92
  DEFAULT_MESSAGE = 'Bad request'
@@ -91,6 +124,126 @@ module Servus
91
124
  def api_error = { code: http_status, message: message }
92
125
  end
93
126
 
127
+ # 405 Method Not Allowed - HTTP method not supported for this resource.
128
+ class MethodNotAllowedError < ServiceError
129
+ DEFAULT_MESSAGE = 'Method not allowed'
130
+
131
+ def http_status = :method_not_allowed
132
+ def api_error = { code: http_status, message: message }
133
+ end
134
+
135
+ # 406 Not Acceptable - requested content type cannot be provided.
136
+ class NotAcceptableError < ServiceError
137
+ DEFAULT_MESSAGE = 'Not acceptable'
138
+
139
+ def http_status = :not_acceptable
140
+ def api_error = { code: http_status, message: message }
141
+ end
142
+
143
+ # 407 Proxy Authentication Required - proxy credentials required.
144
+ class ProxyAuthenticationRequiredError < ServiceError
145
+ DEFAULT_MESSAGE = 'Proxy authentication required'
146
+
147
+ def http_status = :proxy_authentication_required
148
+ def api_error = { code: http_status, message: message }
149
+ end
150
+
151
+ # 408 Request Timeout - client did not produce a request in time.
152
+ class RequestTimeoutError < ServiceError
153
+ DEFAULT_MESSAGE = 'Request timeout'
154
+
155
+ def http_status = :request_timeout
156
+ def api_error = { code: http_status, message: message }
157
+ end
158
+
159
+ # 409 Conflict - request conflicts with current state of the resource.
160
+ class ConflictError < ServiceError
161
+ DEFAULT_MESSAGE = 'Conflict'
162
+
163
+ def http_status = :conflict
164
+ def api_error = { code: http_status, message: message }
165
+ end
166
+
167
+ # 410 Gone - resource is no longer available and will not be available again.
168
+ class GoneError < ServiceError
169
+ DEFAULT_MESSAGE = 'Gone'
170
+
171
+ def http_status = :gone
172
+ def api_error = { code: http_status, message: message }
173
+ end
174
+
175
+ # 411 Length Required - Content-Length header is required.
176
+ class LengthRequiredError < ServiceError
177
+ DEFAULT_MESSAGE = 'Length required'
178
+
179
+ def http_status = :length_required
180
+ def api_error = { code: http_status, message: message }
181
+ end
182
+
183
+ # 412 Precondition Failed - precondition in headers evaluated to false.
184
+ class PreconditionFailedError < ServiceError
185
+ DEFAULT_MESSAGE = 'Precondition failed'
186
+
187
+ def http_status = :precondition_failed
188
+ def api_error = { code: http_status, message: message }
189
+ end
190
+
191
+ # 413 Payload Too Large - request entity is larger than server limits.
192
+ class PayloadTooLargeError < ServiceError
193
+ DEFAULT_MESSAGE = 'Payload too large'
194
+
195
+ def http_status = :payload_too_large
196
+ def api_error = { code: http_status, message: message }
197
+ end
198
+
199
+ # 414 URI Too Long - URI is too long for the server to process.
200
+ class UriTooLongError < ServiceError
201
+ DEFAULT_MESSAGE = 'URI too long'
202
+
203
+ def http_status = :uri_too_long
204
+ def api_error = { code: http_status, message: message }
205
+ end
206
+
207
+ # 415 Unsupported Media Type - request entity has unsupported media type.
208
+ class UnsupportedMediaTypeError < ServiceError
209
+ DEFAULT_MESSAGE = 'Unsupported media type'
210
+
211
+ def http_status = :unsupported_media_type
212
+ def api_error = { code: http_status, message: message }
213
+ end
214
+
215
+ # 416 Range Not Satisfiable - client requested a portion that cannot be supplied.
216
+ class RangeNotSatisfiableError < ServiceError
217
+ DEFAULT_MESSAGE = 'Range not satisfiable'
218
+
219
+ def http_status = :range_not_satisfiable
220
+ def api_error = { code: http_status, message: message }
221
+ end
222
+
223
+ # 417 Expectation Failed - server cannot meet Expect header requirements.
224
+ class ExpectationFailedError < ServiceError
225
+ DEFAULT_MESSAGE = 'Expectation failed'
226
+
227
+ def http_status = :expectation_failed
228
+ def api_error = { code: http_status, message: message }
229
+ end
230
+
231
+ # 418 I'm a Teapot - server refuses to brew coffee because it is a teapot.
232
+ class ImATeapotError < ServiceError
233
+ DEFAULT_MESSAGE = "I'm a teapot"
234
+
235
+ def http_status = :im_a_teapot
236
+ def api_error = { code: http_status, message: message }
237
+ end
238
+
239
+ # 421 Misdirected Request - request was directed at a server unable to respond.
240
+ class MisdirectedRequestError < ServiceError
241
+ DEFAULT_MESSAGE = 'Misdirected request'
242
+
243
+ def http_status = :misdirected_request
244
+ def api_error = { code: http_status, message: message }
245
+ end
246
+
94
247
  # 422 Unprocessable Entity - semantic errors in request.
95
248
  class UnprocessableEntityError < ServiceError
96
249
  DEFAULT_MESSAGE = 'Unprocessable entity'
@@ -99,6 +252,14 @@ module Servus
99
252
  def api_error = { code: http_status, message: message }
100
253
  end
101
254
 
255
+ # 422 Unprocessable Content - content could not be processed.
256
+ class UnprocessableContentError < UnprocessableEntityError
257
+ DEFAULT_MESSAGE = 'Unprocessable content'
258
+
259
+ def http_status = :unprocessable_content
260
+ def api_error = { code: http_status, message: message }
261
+ end
262
+
102
263
  # 422 Validation Error - schema or business validation failed.
103
264
  class ValidationError < UnprocessableEntityError
104
265
  DEFAULT_MESSAGE = 'Validation failed'
@@ -106,33 +267,68 @@ module Servus
106
267
  def api_error = { code: http_status, message: message }
107
268
  end
108
269
 
109
- # Guard validation failure with custom code.
110
- #
111
- # Guards define their own error code and HTTP status via the DSL.
112
- #
113
- # @example
114
- # GuardError.new("Amount must be positive", code: 'invalid_amount', http_status: 422)
115
- class GuardError < ServiceError
116
- DEFAULT_MESSAGE = 'Guard validation failed'
270
+ # 423 Locked - resource is locked.
271
+ class LockedError < ServiceError
272
+ DEFAULT_MESSAGE = 'Locked'
117
273
 
118
- # @return [String] application-specific error code
119
- attr_reader :code
274
+ def http_status = :locked
275
+ def api_error = { code: http_status, message: message }
276
+ end
120
277
 
121
- # @return [Symbol, Integer] HTTP status code
122
- attr_reader :http_status
278
+ # 424 Failed Dependency - request failed due to failure of a previous request.
279
+ class FailedDependencyError < ServiceError
280
+ DEFAULT_MESSAGE = 'Failed dependency'
123
281
 
124
- # Creates a new guard error with metadata.
125
- #
126
- # @param message [String, nil] error message
127
- # @param code [String] error code for API responses (default: 'guard_failed')
128
- # @param http_status [Symbol, Integer] HTTP status (default: :unprocessable_entity)
129
- def initialize(message = nil, code: 'guard_failed', http_status: :unprocessable_entity)
130
- super(message)
131
- @code = code
132
- @http_status = http_status
133
- end
282
+ def http_status = :failed_dependency
283
+ def api_error = { code: http_status, message: message }
284
+ end
134
285
 
135
- def api_error = { code: code, message: message }
286
+ # 425 Too Early - server unwilling to process request that might be replayed.
287
+ class TooEarlyError < ServiceError
288
+ DEFAULT_MESSAGE = 'Too early'
289
+
290
+ def http_status = :too_early
291
+ def api_error = { code: http_status, message: message }
292
+ end
293
+
294
+ # 426 Upgrade Required - client should switch to a different protocol.
295
+ class UpgradeRequiredError < ServiceError
296
+ DEFAULT_MESSAGE = 'Upgrade required'
297
+
298
+ def http_status = :upgrade_required
299
+ def api_error = { code: http_status, message: message }
300
+ end
301
+
302
+ # 428 Precondition Required - origin server requires the request to be conditional.
303
+ class PreconditionRequiredError < ServiceError
304
+ DEFAULT_MESSAGE = 'Precondition required'
305
+
306
+ def http_status = :precondition_required
307
+ def api_error = { code: http_status, message: message }
308
+ end
309
+
310
+ # 429 Too Many Requests - user has sent too many requests in a given time.
311
+ class TooManyRequestsError < ServiceError
312
+ DEFAULT_MESSAGE = 'Too many requests'
313
+
314
+ def http_status = :too_many_requests
315
+ def api_error = { code: http_status, message: message }
316
+ end
317
+
318
+ # 431 Request Header Fields Too Large - server unwilling to process due to header size.
319
+ class RequestHeaderFieldsTooLargeError < ServiceError
320
+ DEFAULT_MESSAGE = 'Request header fields too large'
321
+
322
+ def http_status = :request_header_fields_too_large
323
+ def api_error = { code: http_status, message: message }
324
+ end
325
+
326
+ # 451 Unavailable For Legal Reasons - resource unavailable due to legal demands.
327
+ class UnavailableForLegalReasonsError < ServiceError
328
+ DEFAULT_MESSAGE = 'Unavailable for legal reasons'
329
+
330
+ def http_status = :unavailable_for_legal_reasons
331
+ def api_error = { code: http_status, message: message }
136
332
  end
137
333
 
138
334
  # 500 Internal Server Error - unexpected server-side failure.
@@ -143,6 +339,22 @@ module Servus
143
339
  def api_error = { code: http_status, message: message }
144
340
  end
145
341
 
342
+ # 501 Not Implemented - server does not support the functionality required.
343
+ class NotImplementedError < ServiceError
344
+ DEFAULT_MESSAGE = 'Not implemented'
345
+
346
+ def http_status = :not_implemented
347
+ def api_error = { code: http_status, message: message }
348
+ end
349
+
350
+ # 502 Bad Gateway - server received an invalid response from upstream.
351
+ class BadGatewayError < ServiceError
352
+ DEFAULT_MESSAGE = 'Bad gateway'
353
+
354
+ def http_status = :bad_gateway
355
+ def api_error = { code: http_status, message: message }
356
+ end
357
+
146
358
  # 503 Service Unavailable - dependency temporarily unavailable.
147
359
  class ServiceUnavailableError < ServiceError
148
360
  DEFAULT_MESSAGE = 'Service unavailable'
@@ -150,6 +362,62 @@ module Servus
150
362
  def http_status = :service_unavailable
151
363
  def api_error = { code: http_status, message: message }
152
364
  end
365
+
366
+ # 504 Gateway Timeout - upstream server did not respond in time.
367
+ class GatewayTimeoutError < ServiceError
368
+ DEFAULT_MESSAGE = 'Gateway timeout'
369
+
370
+ def http_status = :gateway_timeout
371
+ def api_error = { code: http_status, message: message }
372
+ end
373
+
374
+ # 505 HTTP Version Not Supported - server does not support the HTTP version.
375
+ class HttpVersionNotSupportedError < ServiceError
376
+ DEFAULT_MESSAGE = 'HTTP version not supported'
377
+
378
+ def http_status = :http_version_not_supported
379
+ def api_error = { code: http_status, message: message }
380
+ end
381
+
382
+ # 506 Variant Also Negotiates - transparent content negotiation error.
383
+ class VariantAlsoNegotiatesError < ServiceError
384
+ DEFAULT_MESSAGE = 'Variant also negotiates'
385
+
386
+ def http_status = :variant_also_negotiates
387
+ def api_error = { code: http_status, message: message }
388
+ end
389
+
390
+ # 507 Insufficient Storage - server unable to store the representation.
391
+ class InsufficientStorageError < ServiceError
392
+ DEFAULT_MESSAGE = 'Insufficient storage'
393
+
394
+ def http_status = :insufficient_storage
395
+ def api_error = { code: http_status, message: message }
396
+ end
397
+
398
+ # 508 Loop Detected - server detected an infinite loop while processing.
399
+ class LoopDetectedError < ServiceError
400
+ DEFAULT_MESSAGE = 'Loop detected'
401
+
402
+ def http_status = :loop_detected
403
+ def api_error = { code: http_status, message: message }
404
+ end
405
+
406
+ # 510 Not Extended - further extensions to the request are required.
407
+ class NotExtendedError < ServiceError
408
+ DEFAULT_MESSAGE = 'Not extended'
409
+
410
+ def http_status = :not_extended
411
+ def api_error = { code: http_status, message: message }
412
+ end
413
+
414
+ # 511 Network Authentication Required - client needs to authenticate for network access.
415
+ class NetworkAuthenticationRequiredError < ServiceError
416
+ DEFAULT_MESSAGE = 'Network authentication required'
417
+
418
+ def http_status = :network_authentication_required
419
+ def api_error = { code: http_status, message: message }
420
+ end
153
421
  end
154
422
  end
155
423
  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.0'
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.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: 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: []