servus 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 36f4d4b15ff9038d9c039f6efb0fe5027c6326811334dc3e478a906b31286d5d
4
+ data.tar.gz: 786b002fe1937d5204088fdb982156bc1fb1ce7bc1c20f2ecd4c737e23e1acd5
5
+ SHA512:
6
+ metadata.gz: 04ce3e7a6cdd2371c4da93d98123ea640736856638dfa5c2a60d3c4b4c7ae1109bd597bf577cec892461677a906e0955ee782c20532901b3808eca80c6ccbb6a
7
+ data.tar.gz: 9c07175986bd05753df2b29dbc70f854cbab12745183d6e8937cc89dd46e5e819df588ac0ef88ea2db8298cb6e470e798a8e3361cc8765194ddee1244c7a2d1c
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-04-28
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Sebastian Scholl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/READme.md ADDED
@@ -0,0 +1,379 @@
1
+ ## Servus Gem
2
+
3
+ Servus is a gem for creating and managing service objects. It includes:
4
+
5
+ - A base class for service objects
6
+ - Generators for core service objects and specs
7
+ - Support for schema validation
8
+ - Support for error handling
9
+ - Support for logging
10
+
11
+
12
+
13
+ ## Generators
14
+
15
+ Service objects can be easily created using the `rails g servus:service namespace/service_name [*params]` command. For sake of consistency, use this command when generating new service objects.
16
+
17
+ ### Generate Service
18
+
19
+ ```bash
20
+ $ rails g servus:service namespace/do_something_helpful user
21
+ => create app/services/namespace/do_something_helpful/service.rb
22
+ create spec/services/namespace/do_something_helpful/service_spec.rb
23
+ create app/schemas/services/namespace/do_something_helpful/result.json
24
+ create app/schemas/services/namespace/do_something_helpful/arguments.json
25
+ ```
26
+
27
+ ### Destroy Service
28
+
29
+ ```bash
30
+ $ rails d servus:service namespace/do_something_helpful
31
+ => remove app/services/namespace/do_something_helpful/service.rb
32
+ remove spec/services/namespace/do_something_helpful/service_spec.rb
33
+ remove app/schemas/services/namespace/do_something_helpful/result.json
34
+ remove app/schemas/services/namespace/do_something_helpful/arguments.json
35
+ ```
36
+
37
+ ## Arguments
38
+
39
+ Service objects should use keyword arguments rather than positional arguments for improved clarity and more meaningful error messages.
40
+
41
+ ```ruby
42
+ # Good ✅
43
+ class Services::ProcessPayment::Service < Servus::Base
44
+ def initialize(user:, amount:, payment_method:)
45
+ @user = user
46
+ @amount = amount
47
+ @payment_method = payment_method
48
+ end
49
+ end
50
+
51
+ # Bad ❌
52
+ class Services::ProcessPayment::Service < Servus::Base
53
+ def initialize(user, amount, payment_method)
54
+ @user = user
55
+ @amount = amount
56
+ @payment_method = payment_method
57
+ end
58
+ end
59
+ ```
60
+
61
+ ## Directory Structure
62
+
63
+ Each service belongs in its own namespace with this structure:
64
+
65
+ - `app/services/service_name/service.rb` - Main class/entry point
66
+ - `app/services/service_name/support/` - Service-specific supporting classes
67
+
68
+ Supporting classes should never be used outside their parent service.
69
+
70
+ ```
71
+ app/services/
72
+ ├── process_payment/
73
+ │ ├── service.rb
74
+ │ └── support/
75
+ │ ├── payment_validator.rb
76
+ │ └── receipt_generator.rb
77
+ ├── generate_report/
78
+ │ ├── service.rb
79
+ │ └── support/
80
+ │ ├── report_formatter.rb
81
+ │ └── data_collector.rb
82
+ ```
83
+
84
+ ## **Methods**
85
+
86
+ Every service object must implement:
87
+
88
+ - An `initialize` method that sets instance variables
89
+ - A parameter-less `call` instance method that executes the service logic
90
+
91
+ ```ruby
92
+ class Services::GenerateReport::Service < Servus::Base
93
+ def initialize(user:, report_type:, date_range:)
94
+ @user = user
95
+ @report_type = report_type
96
+ @date_range = date_range
97
+ end
98
+
99
+ def call
100
+ data = collect_data
101
+ if data.empty?
102
+ return failure("No data available for the selected date range")
103
+ end
104
+
105
+ formatted_report = format_report(data)
106
+ success(formatted_report)
107
+ end
108
+
109
+ private
110
+
111
+ def collect_data
112
+ # Implementation details...
113
+ end
114
+
115
+ def format_report(data)
116
+ # Implementation details...
117
+ end
118
+ end
119
+
120
+ ```
121
+
122
+ ## **Inheritance**
123
+
124
+ - Every main service class (`service.rb`) must inherit from `Servus::Base`
125
+ - Supporting classes should NOT inherit from `Servus::Base`
126
+
127
+ ```ruby
128
+ # Good ✅
129
+ class Services::NotifyUser::Service < Servus::Base
130
+ # Service implementation
131
+ end
132
+
133
+ class Services::NotifyUser::Support::MessageBuilder
134
+ # Support class implementation (does NOT inherit from BaseService)
135
+ end
136
+
137
+ # Bad ❌
138
+ class Services::NotifyUser::Support::MessageBuilder < Servus::Base
139
+ # Incorrect: support classes should not inherit from Base class
140
+ end
141
+ ```
142
+
143
+ ## **Call Chain**
144
+
145
+ Always use the class method `call` instead of manual instantiation. The `call` method:
146
+
147
+ 1. Initializes an instance of the service using provided keyword arguments
148
+ 2. Calls the instance-level `call` method
149
+ 3. Handles schema validation of inputs and outputs
150
+ 4. Handles logging of inputs and results
151
+
152
+ ```ruby
153
+ # Good ✅
154
+ result = Services::ProcessPayment::Service.call(
155
+ amount: 50,
156
+ user_id: 123,
157
+ payment_method: "credit_card"
158
+ )
159
+
160
+ # Bad ❌ - bypasses logging and other class-level functionality
161
+ service = Services::ProcessPayment::Service.new(
162
+ amount: 50,
163
+ user_id: 123,
164
+ payment_method: "credit_card"
165
+ )
166
+ result = service.call
167
+
168
+ ```
169
+
170
+ When services call other services, always use the class-level `call` method:
171
+
172
+ ```ruby
173
+ def process_order
174
+ # Good ✅
175
+ payment_result = Services::ProcessPayment::Service.call(
176
+ amount: @order.total,
177
+ payment_method: @payment_details
178
+ )
179
+
180
+ # Bad ❌
181
+ payment_service = Services::ProcessPayment::Service.new(
182
+ amount: @order.total,
183
+ payment_method: @payment_details
184
+ )
185
+ payment_result = payment_service.call
186
+ end
187
+
188
+ ```
189
+
190
+ ## **Responses**
191
+
192
+ The `Servus::Base` provides standardized response methods:
193
+
194
+ - `success(data)` - Returns success with data as a single argument
195
+ - `failure(message, **options)` - Logs error and returns failure response
196
+ - `error!(message)` - Logs error and raises exception
197
+
198
+ ```ruby
199
+ def call
200
+ # Return failure with message
201
+ return failure("Order is not in a pending state") unless @order.pending?
202
+
203
+ # Do something important
204
+
205
+ # Process and return success with single data object
206
+ success({
207
+ order_id: @order.id,
208
+ status: "processed",
209
+ timestamp: Time.now
210
+ })
211
+ end
212
+ ```
213
+
214
+ All responses are `Servus::Support::Response` objects with a `success?` boolean attribute and either `data` (for success) or `error` (for error) attributes.
215
+
216
+ ### Service Error Returns and Handling
217
+
218
+ By default, the `failure(...)` method creates an instance of `ServiceError` and adds it to the response type's `error` attribute. Standard and custom error types should inherit from the `ServiceError` class and optionally implement a custom `api_error` method. This enables developers to choose between using an API-specific error or generic error message in the calling context.
219
+
220
+ ```ruby
221
+ # Called from within a Service Object
222
+ class SomeServiceObject::Service < Servus::Base
223
+ def call
224
+ # Return default ServiceError with custom message
225
+ failure("That didn't work for some reason")
226
+ #=> Response(false, nil, ApplicationService::Support::Errors::ServiceError("That didn't work for some reason"))
227
+ #
228
+ # OR
229
+ #
230
+ # Specify ServiceError type with custom message
231
+ failure("Custom message", type: Servus::Support::Errors::NotFoundError)
232
+ #=> Response(false, nil, ApplicationService::Support::Errors::NotFoundError("Custom message"))
233
+ #
234
+ # OR
235
+ #
236
+ # Specify ServiceError type with default message
237
+ failure(type: Servus::Support::Errors::NotFoundError)
238
+ #=> Response(false, nil, ApplicationService::Support::Errors::NotFoundError("Record not found"))
239
+ #
240
+ # OR
241
+ #
242
+ # Accept all defaults
243
+ failure
244
+ #=> Response(false, nil, ApplicationService::Support::Errors::ServiceError("An error occurred"))
245
+ end
246
+ end
247
+
248
+ # Error handling in parent context
249
+ class SomeController < AppController
250
+ def controller_action
251
+ result = SomeServiceObject::Service.call(arg: 1)
252
+
253
+ return if result.success?
254
+
255
+ # If you just want the error message
256
+ bad_request(result.error.message)
257
+
258
+ # If you want the API error
259
+ service_object_error(result.error.api_error)
260
+ end
261
+ end
262
+ ```
263
+
264
+ ## **Schema Validation**
265
+
266
+ Service objects support two methods for schema validation: JSON Schema files and inline schema declarations.
267
+
268
+ ### 1. File-based Schema Validation
269
+
270
+ Every service can have corresponding schema files in the centralized schema directory:
271
+
272
+ - `app/schemas/services/service_name/arguments.json` - Validates input arguments
273
+ - `app/schemas/services/service_name/result.json` - Validates success response data
274
+
275
+ Example `arguments.json`:
276
+
277
+ ```json
278
+ {
279
+ "type": "object",
280
+ "required": ["user_id", "amount", "payment_method"],
281
+ "properties": {
282
+ "user_id": { "type": "integer" },
283
+ "amount": {
284
+ "type": "integer",
285
+ "minimum": 1
286
+ },
287
+ "payment_method": {
288
+ "type": "string",
289
+ "enum": ["credit_card", "paypal", "bank_transfer"]
290
+ },
291
+ "currency": {
292
+ "type": "string",
293
+ "default": "USD"
294
+ }
295
+ },
296
+ "additionalProperties": false
297
+ }
298
+
299
+ ```
300
+
301
+ Example `result.json`:
302
+
303
+ ```json
304
+ {
305
+ "type": "object",
306
+ "required": ["transaction_id", "status"],
307
+ "properties": {
308
+ "transaction_id": { "type": "string" },
309
+ "status": {
310
+ "type": "string",
311
+ "enum": ["approved", "pending", "declined"]
312
+ },
313
+ "receipt_url": { "type": "string" }
314
+ }
315
+ }
316
+
317
+ ```
318
+
319
+ ### 2. Inline Schema Validation
320
+
321
+ Alternatively, schemas can be declared directly within the service class using `ARGUMENTS_SCHEMA` and `RESULT_SCHEMA` constants.
322
+
323
+ ```ruby
324
+ class Services::ProcessPayment::Service < Servus::Base
325
+ ARGUMENTS_SCHEMA = {
326
+ type: "object",
327
+ required: ["user_id", "amount", "payment_method"],
328
+ properties: {
329
+ user_id: { type: "integer" },
330
+ amount: {
331
+ type: "integer",
332
+ minimum: 1
333
+ },
334
+ payment_method: {
335
+ type: "string",
336
+ enum: ["credit_card", "paypal", "bank_transfer"]
337
+ },
338
+ currency: {
339
+ type: "string",
340
+ default: "USD"
341
+ }
342
+ },
343
+ additionalProperties: false
344
+ }
345
+
346
+ RESULT_SCHEMA = {
347
+ type: "object",
348
+ required: ["transaction_id", "status"],
349
+ properties: {
350
+ transaction_id: { type: "string" },
351
+ status: {
352
+ type: "string",
353
+ enum: ["approved", "pending", "declined"]
354
+ },
355
+ receipt_url: { type: "string" }
356
+ }
357
+ }
358
+ end
359
+ ```
360
+
361
+ ---
362
+
363
+ These schemas use JSON Schema format to enforce type safety and input/output contracts. For detailed information on authoring JSON Schema files, refer to the official specification at: https://json-schema.org/specification.html
364
+
365
+ ### Schema Resolution
366
+
367
+ The validation system follows this precedence:
368
+
369
+ 1. Checks for inline schema constants (`ARGUMENTS_SCHEMA` or `RESULT_SCHEMA`)
370
+ 2. Falls back to JSON files if no inline schema is found
371
+ 3. Returns nil if neither exists
372
+
373
+ ### Schema Caching
374
+
375
+ Both file-based and inline schemas are automatically cached:
376
+
377
+ - First validation request loads and caches the schema
378
+ - Subsequent validations use the cached version
379
+ - Cache can be cleared using `SchemaValidation.clear_cache!`
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Generators
5
+ # Servus Generator
6
+ class ServiceGenerator < Rails::Generators::NamedBase
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ argument :parameters, type: :array, default: [], banner: "parameter"
10
+
11
+ def create_service_file
12
+ template "service.rb.erb", service_path
13
+ template "service_spec.rb.erb", service_path_spec
14
+
15
+ # Template json schemas
16
+ template "result.json.erb", service_result_schema_path
17
+ template "arguments.json.erb", service_arguments_shecma_path
18
+ end
19
+
20
+ private
21
+
22
+ def service_path
23
+ "app/services/#{file_path}/service.rb"
24
+ end
25
+
26
+ def service_path_spec
27
+ "spec/services/#{file_path}/service_spec.rb"
28
+ end
29
+
30
+ def service_result_schema_path
31
+ "app/schemas/services/#{file_path}/result.json"
32
+ end
33
+
34
+ def service_arguments_shecma_path
35
+ "app/schemas/services/#{file_path}/arguments.json"
36
+ end
37
+
38
+ def service_class_name
39
+ "#{class_name}::Service"
40
+ end
41
+
42
+ def service_full_class_name
43
+ service_class_name.include?("::") ? service_class_name : "::#{service_class_name}"
44
+ end
45
+
46
+ def parameter_list
47
+ return "" if parameters.empty?
48
+
49
+ "(#{parameters.map { |param| "#{param}:" }.join(", ")})"
50
+ end
51
+
52
+ def initialize_params
53
+ parameters.map { |param| "@#{param} = #{param}" }.join("\n ")
54
+ end
55
+
56
+ def attr_readers
57
+ return "" if parameters.empty?
58
+
59
+ "attr_reader #{parameters.map { |param| ":#{param}" }.join(", ")}"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,15 @@
1
+ {
2
+ "type": "object",
3
+ "properties": {
4
+ <%- parameters.each do |param| %>
5
+ "<%= param %>": {
6
+ "type": "UNDECLARED_TYPE"
7
+ }
8
+ <% end %>
9
+ },
10
+ "required": [
11
+ <%- parameters.each do |param| %>
12
+ "<%= param %>",
13
+ <% end %>
14
+ ]
15
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "object",
3
+ "properties": {}
4
+ }
@@ -0,0 +1,12 @@
1
+ module <%= class_name %>
2
+ class Service < Servus::ApplicationService
3
+ def initialize<%= parameter_list %>
4
+ <%= initialize_params %>
5
+ end
6
+ def call
7
+ # Implement service logic here
8
+ success({})
9
+ end
10
+ <%= attr_readers %>
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe <%= service_full_class_name %> do
4
+ describe "#call" do
5
+ it "does something" do
6
+ # Add expectations here
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ class Base
5
+ include Servus::Support::Errors
6
+
7
+ # Support class aliases
8
+ Logger = Servus::Support::Logger
9
+ Response = Servus::Support::Response
10
+ Validator = Servus::Support::Validator
11
+
12
+ # Calls the service and returns a response
13
+ # @param args [Hash] The arguments to pass to the service
14
+ # @return [Servus::Support::Response] The response
15
+ # @raise [StandardError] If an exception is raised
16
+ # @raise [Servus::Support::Errors::ValidationError] If result is invalid
17
+ # @raise [Servus::Support::Errors::ValidationError] If arguments are invalid
18
+ def self.call(**args)
19
+ Logger.log_call(self, args)
20
+
21
+ Validator.validate_arguments!(self, args)
22
+
23
+ result = benchmark(**args) do
24
+ new(**args).call
25
+ end
26
+
27
+ Validator.validate_result!(self, result)
28
+
29
+ result
30
+ rescue ValidationError => e
31
+ Logger.log_validation_error(self, e)
32
+ raise e
33
+ rescue StandardError => e
34
+ Logger.log_exception(self, e)
35
+ raise e
36
+ end
37
+
38
+ # Returns a success response
39
+ # @param data [Object] The data to return
40
+ # @return [Servus::Support::Response] The success response
41
+ def success(data)
42
+ Response.new(true, data, nil)
43
+ end
44
+
45
+ # Returns a failure response
46
+ # @param message [String] The error message
47
+ # @param type [Class] The error type
48
+ # @return [Servus::Support::Response] The failure response
49
+ def failure(message = nil, type: Servus::Support::Errors::ServiceError)
50
+ error = type.new(message)
51
+ Response.new(false, nil, error)
52
+ end
53
+
54
+ # Raises an error and logs it
55
+ # @param message [String] The error message
56
+ # @param type [Class] The error type
57
+ # @return [void]
58
+ def error!(message = nil, type: Servus::Support::Errors::ServiceError)
59
+ Logger.log_exception(self.class, type.new(message))
60
+ raise type, message
61
+ end
62
+
63
+ # Benchmarks the call
64
+ # @param args [Hash] The arguments to pass to the service
65
+ # @return [Object] The result of the call
66
+ def self.benchmark(**_args)
67
+ start_time = Time.now.utc
68
+ result = yield
69
+ duration = Time.now.utc - start_time
70
+
71
+ Logger.log_result(self, result, duration)
72
+
73
+ result
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ class Config
5
+ # The directory where schemas are loaded from, can be set by the user
6
+ attr_reader :schema_root
7
+
8
+ def initialize
9
+ # Default to Rails.root if available, otherwise use current working directory
10
+ @schema_root = if defined?(Rails)
11
+ Rails.root.join("app/schemas/services")
12
+ else
13
+ File.expand_path("../../../app/schemas/services", __dir__)
14
+ end
15
+ end
16
+
17
+ # Returns the path for a specific service's schema
18
+ #
19
+ # @param service_namespace [String] the namespace of the service
20
+ # @param type [String] the type of the schema (e.g., "arguments", "result")
21
+ # @return [String] the path for the service's schema type
22
+ def schema_path_for(service_namespace, type)
23
+ File.join(schema_root.to_s, service_namespace, "#{type}.json")
24
+ end
25
+
26
+ # Returns the directory for a specific service
27
+ #
28
+ # @param service_namespace [String] the namespace of the service
29
+ # @return [String] the directory for the service's schemas
30
+ def schema_dir_for(service_namespace)
31
+ File.join(schema_root.to_s, service_namespace)
32
+ end
33
+ end
34
+
35
+ # Singleton config instance
36
+ def self.config
37
+ @config ||= Config.new
38
+ end
39
+
40
+ def self.configure
41
+ yield(config)
42
+ end
43
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/servus/railtie.rb
4
+ require "rails/railtie"
5
+
6
+ module Servus
7
+ class Railtie < Rails::Railtie; end
8
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Support
5
+ module Errors
6
+ # Base error class for application services
7
+ # @param message [String] The error message
8
+ # @return [ServiceError] The error instance
9
+ class ServiceError < StandardError
10
+ attr_reader :message
11
+
12
+ DEFAULT_MESSAGE = "An error occurred"
13
+
14
+ # Initializes a new error instance
15
+ # @param message [String] The error message
16
+ # @return [ServiceError] The error instance
17
+ def initialize(message = nil)
18
+ @message = message || self.class::DEFAULT_MESSAGE
19
+ super("#{self.class}: #{message}")
20
+ end
21
+
22
+ # 404 error response
23
+ # @return [Hash] The error response
24
+ def api_error
25
+ { code: :bad_request, message: message }
26
+ end
27
+ end
28
+
29
+ # Error class for bad request errors
30
+ # @param message [String] The error message
31
+ # @return [BadRequestError] The error instance
32
+ class BadRequestError < ServiceError
33
+ DEFAULT_MESSAGE = "Bad request"
34
+
35
+ # 400 error response
36
+ # @return [Hash] The error response
37
+ def api_error
38
+ { code: :bad_request, message: message }
39
+ end
40
+ end
41
+
42
+ # Error class for authentication errors
43
+ # @param message [String] The error message
44
+ # @return [AuthenticationError] The error instance
45
+ class AuthenticationError < ServiceError
46
+ DEFAULT_MESSAGE = "Authentication failed"
47
+
48
+ # 401 error response
49
+ # @return [Hash] The error response
50
+ def api_error
51
+ { code: :unauthorized, message: message }
52
+ end
53
+ end
54
+
55
+ # Error class for unauthorized errors
56
+ # @param message [String] The error message
57
+ # @return [UnauthorizedError] The error instance
58
+ class UnauthorizedError < AuthenticationError
59
+ DEFAULT_MESSAGE = "Unauthorized"
60
+ end
61
+
62
+ # Error class for forbidden errors
63
+ # @param message [String] The error message
64
+ # @return [ForbiddenError] The error instance
65
+ class ForbiddenError < ServiceError
66
+ DEFAULT_MESSAGE = "Forbidden"
67
+
68
+ # 403 error response
69
+ # @return [Hash] The error response
70
+ def api_error
71
+ { code: :forbidden, message: message }
72
+ end
73
+ end
74
+
75
+ # Error class for not found errors
76
+ # @param message [String] The error message
77
+ # @return [NotFoundError] The error instance
78
+ class NotFoundError < ServiceError
79
+ DEFAULT_MESSAGE = "Not found"
80
+
81
+ # 404 error response
82
+ # @return [Hash] The error response
83
+ def api_error
84
+ { code: :not_found, message: message }
85
+ end
86
+ end
87
+
88
+ # Error class for unprocessable entity errors
89
+ # @param message [String] The error message
90
+ # @return [UnprocessableEntityError] The error instance
91
+ class UnprocessableEntityError < ServiceError
92
+ DEFAULT_MESSAGE = "Unprocessable entity"
93
+
94
+ # 422 error response
95
+ # @return [Hash] The error response
96
+ def api_error
97
+ { code: :unprocessable_entity, message: message }
98
+ end
99
+ end
100
+
101
+ # Error class for validation errors
102
+ # @param message [String] The error message
103
+ # @return [ValidationError] The error instance
104
+ class ValidationError < UnprocessableEntityError
105
+ DEFAULT_MESSAGE = "Validation failed"
106
+ end
107
+
108
+ # Error class for internal server errors
109
+ # @param message [String] The error message
110
+ # @return [InternalServerError] The error instance
111
+ class InternalServerError < ServiceError
112
+ DEFAULT_MESSAGE = "Internal server error"
113
+
114
+ # 500 error response
115
+ # @return [Hash] The error response
116
+ def api_error
117
+ { code: :internal_server_error, message: message }
118
+ end
119
+ end
120
+
121
+ # Error class for service unavailable errors
122
+ # @param message [String] The error message
123
+ # @return [ServiceUnavailableError] The error instance
124
+ class ServiceUnavailableError < ServiceError
125
+ DEFAULT_MESSAGE = "Service unavailable"
126
+
127
+ # 503 error response
128
+ # @return [Hash] The error response
129
+ def api_error
130
+ { code: :service_unavailable, message: message }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Servus
6
+ module Support
7
+ class Logger
8
+ # Returns the logger instance depending on the environment
9
+ #
10
+ # @return [Logger] The logger instance
11
+ def self.logger
12
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
13
+ Rails.logger
14
+ else
15
+ @logger ||= ::Logger.new($stdout)
16
+ end
17
+ end
18
+
19
+ # Logs a call to a service
20
+ #
21
+ # @param service_class [Class] The service class
22
+ # @param args [Hash] The arguments passed to the service
23
+ def self.log_call(service_class, args)
24
+ logger.info("Calling #{service_class.name} with args: #{args.inspect}")
25
+ end
26
+
27
+ # Logs a result from a service
28
+ #
29
+ # @param service_class [Class] The service class
30
+ # @param result [Servus::Support::Response] The result from the service
31
+ # @param duration [Float] The duration of the service call
32
+ def self.log_result(service_class, result, duration)
33
+ if result.success?
34
+ log_success(service_class, duration)
35
+ else
36
+ log_failure(service_class, result.error, duration)
37
+ end
38
+ end
39
+
40
+ # Logs a successful result from a service
41
+ #
42
+ # @param service_class [Class] The service class
43
+ # @param duration [Float] The duration of the service call
44
+ def self.log_success(service_class, duration)
45
+ logger.info("#{service_class.name} succeeded in #{duration.round(3)}s")
46
+ end
47
+
48
+ # Logs a failed result from a service
49
+ #
50
+ # @param service_class [Class] The service class
51
+ # @param error [Servus::Support::Errors::ServiceError] The error from the service
52
+ # @param duration [Float] The duration of the service call
53
+ def self.log_failure(service_class, error, duration)
54
+ logger.warn("#{service_class.name} failed in #{duration.round(3)}s with error: #{error}")
55
+ end
56
+
57
+ # Logs a validation error from a service
58
+ #
59
+ # @param service_class [Class] The service class
60
+ # @param error [Servus::Support::Errors::ValidationError] The validation error
61
+ def self.log_validation_error(service_class, error)
62
+ logger.error("#{service_class.name} validation error: #{error.message}")
63
+ end
64
+
65
+ # Logs an uncaught exception from a service
66
+ #
67
+ # @param service_class [Class] The service class
68
+ # @param exception [Exception] The uncaught exception
69
+ def self.log_exception(service_class, exception)
70
+ logger.error("#{service_class.name} uncaught exception: #{exception.class} - #{exception.message}")
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Support
5
+ class Response
6
+ attr_reader :data, :error
7
+
8
+ def initialize(success, data, error)
9
+ @success = success
10
+ @data = data
11
+ @error = error
12
+ end
13
+
14
+ def success?
15
+ @success
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Support
5
+ class Validator
6
+ # Class-level schema cache
7
+ @schema_cache = {}
8
+
9
+ # Validate service arguments against schema
10
+ def self.validate_arguments!(service_class, args)
11
+ schema = load_schema(service_class, "arguments")
12
+ return true unless schema # Skip validation if no schema exists
13
+
14
+ serialized_result = args.as_json
15
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_result)
16
+
17
+ if validation_errors.any?
18
+ error_message = "Invalid arguments for #{service_class.name}: #{validation_errors.join(", ")}"
19
+ raise Servus::Base::ValidationError, error_message
20
+ end
21
+
22
+ true
23
+ end
24
+
25
+ # Validate service result against schema
26
+ def self.validate_result!(service_class, result)
27
+ return result unless result.success?
28
+
29
+ schema = load_schema(service_class, "result")
30
+ return result unless schema # Skip validation if no schema exists
31
+
32
+ serialized_result = result.data.as_json
33
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_result)
34
+
35
+ if validation_errors.any?
36
+ error_message = "Invalid result structure from #{service_class.name}: #{validation_errors.join(", ")}"
37
+ raise Servus::Base::ValidationError, error_message
38
+ end
39
+
40
+ result
41
+ end
42
+
43
+ # Load schema from file with caching
44
+ def self.load_schema(service_class, type)
45
+ # Get service path based on class name (e.g., "process_payment" from "Servus::ProcessPayment::Service")
46
+ service_namespace = service_class.name.split("::")[..-2].map do |s|
47
+ s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
48
+ end.join("/")
49
+ schema_path = Servus.config.schema_path_for(service_namespace, type)
50
+
51
+ # Return from cache if available
52
+ return @schema_cache[schema_path] if @schema_cache.key?(schema_path)
53
+
54
+ inline_schema_constant_name = "#{service_class}::#{type.upcase}_SCHEMA"
55
+ inline_schema_constant = Object.const_defined?(inline_schema_constant_name) ? Object.const_get(inline_schema_constant_name) : nil
56
+
57
+ if inline_schema_constant
58
+ @schema_cache[schema_path] =
59
+ inline_schema_constant.respond_to?(:deep_stringify_keys) ? inline_schema_constant.deep_stringify_keys : inline_schema_constant
60
+ elsif File.exist?(schema_path)
61
+ @schema_cache[schema_path] = JSON.parse(File.read(schema_path))
62
+ else
63
+ # Cache nil result to avoid checking file system again
64
+ @schema_cache[schema_path] = nil
65
+ end
66
+
67
+ @schema_cache[schema_path]
68
+ end
69
+
70
+ # Clear the schema cache (useful for testing or development)
71
+ def self.clear_cache!
72
+ @schema_cache = {}
73
+ end
74
+
75
+ # Returns the schema cache
76
+ def self.cache
77
+ @schema_cache
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ VERSION = "0.0.1"
5
+ end
data/lib/servus.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Globals
4
+ require "json-schema"
5
+ require "active_model_serializers"
6
+
7
+ # Servus namespace
8
+ module Servus; end
9
+
10
+ # Railtie
11
+ require_relative "servus/railtie" if defined?(Rails::Railtie)
12
+
13
+ # Config
14
+ require_relative "servus/config"
15
+
16
+ # Support
17
+ require_relative "servus/support/logger"
18
+ require_relative "servus/support/response"
19
+ require_relative "servus/support/validator"
20
+ require_relative "servus/support/errors"
21
+
22
+ # Core
23
+ require_relative "servus/version"
24
+ require_relative "servus/base"
data/sig/servus.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Servus
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: servus
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Scholl
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: active_model_serializers
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: json-schema
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: A gem for managing service objects.
41
+ email:
42
+ - sebscholl@gmail.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - ".rspec"
48
+ - CHANGELOG.md
49
+ - LICENSE.txt
50
+ - READme.md
51
+ - Rakefile
52
+ - lib/generators/servus/service/service_generator.rb
53
+ - lib/generators/servus/service/templates/arguments.json.erb
54
+ - lib/generators/servus/service/templates/result.json.erb
55
+ - lib/generators/servus/service/templates/service.rb.erb
56
+ - lib/generators/servus/service/templates/service_spec.rb.erb
57
+ - lib/servus.rb
58
+ - lib/servus/base.rb
59
+ - lib/servus/config.rb
60
+ - lib/servus/railtie.rb
61
+ - lib/servus/support/errors.rb
62
+ - lib/servus/support/logger.rb
63
+ - lib/servus/support/response.rb
64
+ - lib/servus/support/validator.rb
65
+ - lib/servus/version.rb
66
+ - sig/servus.rbs
67
+ homepage: https://github.com/zarpay/servus
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ allowed_push_host: https://rubygems.org
72
+ homepage_uri: https://github.com/zarpay/servus
73
+ source_code_uri: https://github.com/zarpay/servus
74
+ changelog_uri: https://github.com/zarpay/servus/blob/main/CHANGELOG.md
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.0.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.6.7
90
+ specification_version: 4
91
+ summary: A gem for managing service objects.
92
+ test_files: []