service_base 1.0.3 โ†’ 1.1.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: 17af4f8d7a5f86ee24adbbbba2118e7b47134f2730e4e04fb0c0107c86cfa168
4
- data.tar.gz: 1ad5a04d75c99bbc061d1ab53ce67d0d8864c132b8be85ebeb4da3b1f759d996
3
+ metadata.gz: 0f709f1e0e410fab6ddd7be7497a63b157433a8137d29a315039bde500c181d4
4
+ data.tar.gz: 436d06c1d55ac4c3e4d5b803a370e26976201bc88c2c934de5ac43f89275b52d
5
5
  SHA512:
6
- metadata.gz: 559196375ec8a48520b076feb989287c43e84cf7a759b91d0db753448f9199fe02f3febff0a06e9d797523f5774e58d26e4745f921820f94298519873d4a5732
7
- data.tar.gz: ec37a4775094ebf67fa9e09b3db800206f31f8cfa5775866675f0213f9f188f5b228bc503e6e2ff7b3827bcfae039cc1dc0285796e96831ef990f182174a5709
6
+ metadata.gz: f2dc449665465e7952604be4700330eedd024681334df857caca45e471a7649bea92a1eaec4a526257d6c1d6d84a6ae30a08009d624f93b98b95085e88b4e266
7
+ data.tar.gz: fae721976b7ef7e82e2d66c18ca4718e5d25673148e98effcf41498d7bea351c52b7d7cbcb4029a13dac73e804f0f00a6c49786b2c067c080813e0e2d81b9ebd
data/CHANGELOG.md ADDED
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.1.0] - 2025-09-09
11
+
12
+ ### Added
13
+ - Support for parameter-less failure and success handlers in ServiceSupport test helpers
14
+ - Flexible block parameter handling that works with both `on.failure { |error| ... }` and `on.failure do ... end` patterns
15
+ - Comprehensive test coverage for parameter-less handler scenarios
16
+ - New helper methods in ServiceSupport for improved code organization
17
+
18
+ ### Changed
19
+ - Refactored ServiceSupport methods to use helper methods for better maintainability
20
+ - Enhanced ServiceSupport stubbing to automatically detect block parameter requirements
21
+ - Improved error handling in test stubs with fallback to parameter-less calls
22
+
23
+ ### Technical Details
24
+ - ServiceSupport methods now use Ruby's `ArgumentError` rescue mechanism to detect block parameter compatibility
25
+ - `stub_service_success` and `stub_service_failure` automatically handle both parameterized and parameter-less blocks
26
+ - Test stubs try calling with parameters first, then fall back to parameter-less calls when blocks don't accept them
27
+ - This enables cleaner test code without requiring unused `|_|` parameters in failure handlers
28
+
29
+ ## [1.0.4] - 2025-08-27
30
+
31
+ ### Added
32
+ - Comprehensive test coverage achieving 100%
33
+ - GitHub Actions workflow for running tests
34
+ - GitHub Actions status badge to README
35
+
36
+ ### Changed
37
+ - Improved README formatting and clarity
38
+
39
+ ### Fixed
40
+ - GitHub Actions workflow compatibility with Rails 8.0+
41
+
42
+ ## [1.0.3] - 2025-04-30
43
+
44
+ ### Fixed
45
+ - ServiceSupport now properly yields success block values in stubbed services
46
+
47
+ ## [1.0.2] - 2025-04-24
48
+
49
+ ### Changed
50
+ - Improved constant namespacing to avoid need for global search with `::`
51
+ - Fixed generator issues for proper file creation
52
+
53
+ ### Fixed
54
+ - Type generator now works correctly
55
+ - Generator template improvements
56
+
57
+ ## [1.0.1] - 2025-04-02
58
+
59
+ ### Changed
60
+ - Removed types and locale support to simplify the gem
61
+ - Improved namespacing - everything now properly namespaced to `ServiceBase::`
62
+ - Enhanced test coverage
63
+
64
+ ### Removed
65
+ - Active Support dependency for lighter footprint
66
+ - Locale support functionality
67
+ - Custom types functionality
68
+
69
+ ## [1.0.0] - 2025-04-01
70
+
71
+ ### Added
72
+ - Initial release of ServiceBase gem
73
+ - Service Object pattern implementation with dry-rb integration
74
+ - Railway-oriented programming using dry-monads
75
+ - Type validation using dry-struct and dry-types
76
+ - ArgumentTypeAnnotations DSL for service arguments
77
+ - Service generators for Rails integration
78
+ - RSpec test helpers with ServiceSupport
79
+ - Comprehensive documentation and examples
80
+
81
+ ### Features
82
+ - Base Service class with automatic type validation
83
+ - Result monad integration (Success/Failure)
84
+ - Service description and pretty-printing support
85
+ - Rails generators for ApplicationService and Type modules
86
+ - Test stubbing utilities for service success/failure scenarios
87
+ - Argument validation with descriptive error messages
data/README.md CHANGED
@@ -1,10 +1,57 @@
1
1
  # Service Base
2
2
 
3
- A base service class for Ruby applications that provides common functionality and argument type annotations DSL.
3
+ [![Test](https://github.com/kleinjm/service_base/actions/workflows/test.yml/badge.svg)](https://github.com/kleinjm/service_base/actions/workflows/test.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/service_base.svg)](https://badge.fury.io/rb/service_base)
4
5
 
5
- ## Dependencies
6
+ A powerful base service class for Ruby applications that implements the Service Object pattern with type-safe arguments and railway-oriented programming using dry-rb gems.
6
7
 
7
- Simply Ruby. This gem can be used in a standalone fashion and Rails is not a dependency. This gem does, however, work very nicely with Rails conventions and includes generators for getting set up quickly.
8
+ ## โœจ Features
9
+
10
+ - ๐Ÿš‚ **Railway-oriented programming** with automatic error handling
11
+ - ๐Ÿ”’ **Type-safe arguments** with validation and coercion
12
+ - ๐Ÿ“ **Self-documenting** services with descriptions
13
+ - ๐Ÿงช **Test helpers** for easy mocking and stubbing
14
+ - โšก **Zero dependencies** - works standalone or with Rails
15
+ - ๐Ÿ› ๏ธ **Rails generators** for quick setup
16
+
17
+ ## Table of Contents
18
+
19
+ - [Quick Start](#quick-start)
20
+ - [Installation](#installation)
21
+ - [Service Pattern Overview](#service-pattern-overview)
22
+ - [Usage](#usage)
23
+ - [Arguments](#arguments)
24
+ - [Types](#types)
25
+ - [Transactions](#working-with-transactions)
26
+ - [Testing](#test-support)
27
+ - [Development](#development)
28
+
29
+ ## Quick Start
30
+
31
+ ```ruby
32
+ # Define a service
33
+ class User::CreateService < ApplicationService
34
+ description "Creates a new user with validation"
35
+
36
+ argument :name, Type::String, description: "User's full name"
37
+ argument :email, Type::String, description: "User's email address"
38
+ argument :age, Type::Integer, optional: true, description: "User's age"
39
+
40
+ def call
41
+ user = User.new(arguments)
42
+ return Failure("Invalid user data") unless user.valid?
43
+
44
+ user.save!
45
+ Success(user)
46
+ end
47
+ end
48
+
49
+ # Use the service
50
+ User::CreateService.call(name: "John Doe", email: "john@example.com") do |on|
51
+ on.success { |user| redirect_to user_path(user) }
52
+ on.failure { |error| render json: { error: error }, status: 422 }
53
+ end
54
+ ```
8
55
 
9
56
  ## Installation
10
57
 
@@ -36,14 +83,11 @@ class ApplicationService < ServiceBase::Service
36
83
  end
37
84
  ```
38
85
 
39
- # Base Service Pattern
86
+ ## Service Pattern Overview
40
87
 
41
- The general concept of a Service Pattern is useful when a you need to execute a set of
42
- sequential steps. The service encapsulates those steps into a single class with a single action to trigger the steps.
88
+ The Service Object pattern is useful when you need to execute a set of sequential steps. The service encapsulates those steps into a single class with a single action to trigger the steps.
43
89
 
44
- The Base Service Pattern uses a modified [Railway
45
- Pattern](https://fsharpforfunandprofit.com/posts/recipe-part2/) set up and enforced by the `Service` class,
46
- which every service inherits from.
90
+ This gem implements a modified [Railway Pattern](https://fsharpforfunandprofit.com/posts/recipe-part2/) that's set up and enforced by the `ServiceBase::Service` class, which every service inherits from.
47
91
 
48
92
  ## Recommended resources
49
93
 
@@ -79,14 +123,48 @@ responsible for handling that logic.
79
123
 
80
124
  One of the best ways to use the service pattern is for CRUD services - Ie. `ActiveRecordModel` + `::CreateService`, `::UpdateService`, `::DeleteService`. This avoids the use of callbacks, mystery guests, and unexpected side effects because all the steps to do a CRUD action are in one place and in order of execution.
81
125
 
82
- ## Returning a Result
126
+ ## Usage
127
+
128
+ ### Defining a Service
129
+
130
+ Every service must:
131
+ 1. Inherit from `ApplicationService` (or `ServiceBase::Service` directly)
132
+ 2. Define a `#call` method that returns `Success(value)` or `Failure(error)`
133
+ 3. Use the `argument` DSL to define typed arguments
134
+ 4. Optionally include a `description` for documentation
135
+
136
+ ```ruby
137
+ class User::UpdateService < ApplicationService
138
+ description "Updates user attributes with validation"
139
+
140
+ argument :user, Type::User, description: "User to update"
141
+ argument :attributes, Type::Hash, description: "Attributes to update"
142
+ argument :notify, Type::Boolean, default: true, description: "Send notification email"
143
+
144
+ def call
145
+ return Failure("User is archived") if user.archived?
146
+
147
+ user.assign_attributes(attributes)
148
+ return Failure(user.errors.full_messages) unless user.valid?
149
+
150
+ user.save!
151
+ send_notification if notify
152
+ Success(user)
153
+ end
154
+
155
+ private
156
+
157
+ def send_notification
158
+ UserMailer.updated(user).deliver_now
159
+ end
160
+ end
161
+ ```
83
162
 
84
- Each service inheriting from BaseService must define `#call` and return a `Success` or `Failure`. These types are `Result` Monads from
85
- the [dry-monads gem](https://dry-rb.org/gems/dry-monads/1.3/). Both `Result` types may take any value as input, ie. `Success(user)`, `Failure(:not_found)`
163
+ ### Calling a Service
86
164
 
87
- `Failure` can return any value youโ€™d like the caller to have in order to understand the failure.
165
+ Services return `Result` monads from the [dry-monads gem](https://dry-rb.org/gems/dry-monads/1.3/). Both `Success` and `Failure` can contain any value, like `Success(user)` or `Failure(:not_found)`.
88
166
 
89
- The caller of service can unwrap the `Success` or `Failure`.
167
+ The caller can unwrap the `Success` or `Failure`:
90
168
 
91
169
  ```ruby
92
170
  MyService.call(name: user.name) do |on|
@@ -146,36 +224,36 @@ end
146
224
 
147
225
  ## Arguments
148
226
 
149
- Arguments to a service are defined via the `argument` DSL.
150
- The positional name and type arguments are required, the other options are as follows.
151
- `argument(:name, Type::String, optional: true, description: "The User's name")`
227
+ Arguments to a service are defined via the `argument` DSL. The positional name and type arguments are required, with additional options available:
228
+
229
+ ```ruby
230
+ argument(:name, Type::String, optional: true, description: "The User's name")
231
+ ```
152
232
 
153
233
  If an argument is optional and has a default value, simply set `default: your_value` but do not also specify `optional: true`.
154
234
  Doing so will raise an `ArgumentError`.
155
235
 
156
- Additionally, be sure to `.freeze` any mutable default values, ie. `default: {}.freeze`.
157
- Failure to do so will raise an `ArgumentError`.
236
+ Additionally, be sure to `.freeze` any mutable default values, e.g., `default: {}.freeze`. Failure to do so will raise an `ArgumentError`.
158
237
 
159
- To allow multiple types as arguments, use `|`. For example,
238
+ To allow multiple types as arguments, use `|`:
160
239
 
161
- ```rb
240
+ ```ruby
162
241
  argument(:value, Type::String | Type::Integer)
163
242
  ```
164
243
 
165
- A service should also define a `description`. This is recommended for self-documentation, ie.
244
+ A service should also define a `description`. This is recommended for self-documentation:
166
245
 
167
246
  ```ruby
168
247
  class MyService < ApplicationService
169
- description("Does a lot of cool things")
248
+ description "Does a lot of cool things"
170
249
  end
171
250
  ```
172
251
 
173
- To get the full hash of `argument`'s keys and values passed into a service,
174
- call `arguments`. This is a very useful technique for services that update an object. For example
252
+ To get the full hash of arguments passed into a service, call `arguments`. This is a very useful technique for services that update an object:
175
253
 
176
254
  ```ruby
177
255
  class User::UpdateService < ApplicationService
178
- argument(:name, String)
256
+ argument :name, Type::String
179
257
 
180
258
  def call
181
259
  user.update(arguments)
@@ -183,12 +261,15 @@ class User::UpdateService < ApplicationService
183
261
  end
184
262
  ```
185
263
 
186
- ### Nil
264
+ ### Nil Values
187
265
 
188
- Empty strings attempted to coerce into integers will throw an error.
189
- See [this GH issue for an explaination](https://github.com/dry-rb/dry-types/issues/344#issuecomment-518743661)
190
- To instead accept `nil`, do the following:
191
- `argument(:some_integer, Type::Params::Nil | Type::Params::Integer)`
266
+ Empty strings attempted to coerce into integers will throw an error. See [this GitHub issue for an explanation](https://github.com/dry-rb/dry-types/issues/344#issuecomment-518743661).
267
+
268
+ To instead accept `nil`, use the following:
269
+
270
+ ```ruby
271
+ argument :some_integer, Type::Params::Nil | Type::Params::Integer
272
+ ```
192
273
 
193
274
 
194
275
  ## Types
@@ -198,7 +279,7 @@ You may also add custom types as outlined in [Dry.rb Custom Types](https://dry-r
198
279
 
199
280
  The Rails generators will create a Type module, which includes `ServiceBase::Types`, which includes `Dry.Types`. Therefore, all types defined in Dry.rb's Types are available to you.
200
281
 
201
- ```rb
282
+ ```ruby
202
283
  # app/models/type.rb
203
284
  module Type
204
285
  include ServiceBase::Types
@@ -211,7 +292,7 @@ module Type
211
292
  # Controller params are an ActionController::Parameters instance or a hash (easier for testing)
212
293
  ControllerParams = Dry.Types.Instance(ActionController::Parameters) | Dry.Types.Instance(Hash)
213
294
 
214
- # Customer param hashes
295
+ # Custom param hashes
215
296
  AddressParams = Dry::Types['hash'].schema(
216
297
  address: Dry::Types['string'],
217
298
  address2: Dry::Types['string'],
@@ -223,40 +304,38 @@ end
223
304
 
224
305
  # app/services/example_service.rb
225
306
  class ExampleService < ApplicationService
226
- argument(:any_model, Type::ApplicationRecord, description: "The model to update")
227
- argument(:params, Type::ControllerParams, description: "The attributes to update")
228
- argument(:user, Type::User, description: "A cool user that relates to the model")
229
- argument(:project, Type::Project, description: "A project that the user is working on")
230
- argument(:address, Type::AddressParams, description: "The user's address")
307
+ argument :any_model, Type::ApplicationRecord, description: "The model to update"
308
+ argument :params, Type::ControllerParams, description: "The attributes to update"
309
+ argument :user, Type::User, description: "A cool user that relates to the model"
310
+ argument :project, Type::Project, description: "A project that the user is working on"
311
+ argument :address, Type::AddressParams, description: "The user's address"
231
312
  end
232
313
  ```
233
314
 
234
- Dry.rb's `Coercible` and `Params` Types are very powerful and recommended for automatic parsing of inputs, ie. controller parameters.
315
+ Dry.rb's `Coercible` and `Params` Types are very powerful and recommended for automatic parsing of inputs, e.g., controller parameters.
235
316
 
236
- For example `argument(:number, Type::Params::Integer)` will convert `"12"` โ‡’ `12`.
317
+ For example, `argument :number, Type::Params::Integer` will convert `"12"` โ‡’ `12`.
237
318
 
238
319
  Entire hash structures may also be validated and automatically parsed, for example:
239
320
 
240
321
  ```ruby
241
- argument(
242
- :line_items,
322
+ argument :line_items,
243
323
  Type::Array(
244
324
  Type::Hash.schema(
245
325
  vintage_year: Type::Params::Integer,
246
326
  number_of_credits: Type::Params::Integer,
247
327
  price_dollars_usd: Type::Params::Float,
248
- ),
249
- ),
328
+ )
329
+ )
250
330
  ```
251
331
 
252
- ## Working with transactions
332
+ ## Working with Transactions
253
333
 
254
334
  โš ๏ธย  If your service makes more than one write call to the DB, you
255
335
  should wrap all operations in a single transaction with
256
- `::ApplicationRecord.transaction`.
336
+ `ApplicationRecord.transaction`.
257
337
 
258
- According to the [Dry
259
- RB docs](https://dry-rb.org/gems/dry-monads/1.3/do-notation/#transaction-safety):
338
+ According to the [Dry-RB documentation](https://dry-rb.org/gems/dry-monads/1.3/do-notation/#transaction-safety):
260
339
 
261
340
  > Under the hood,ย Do uses exceptions to halt unsuccessful
262
341
  operationsโ€ฆSinceย yield internally uses exceptions to
@@ -303,7 +382,7 @@ blocks or other sub-modules. See [https://github.com/dry-rb/dry-monads/issues/68
303
382
 
304
383
  ## Misc
305
384
 
306
- - To get a pretty printed description of a service and itโ€™s args, run `ServiceClass.pp`
385
+ - To get a pretty printed description of a service and its args, run `ServiceClass.pp`
307
386
 
308
387
  ## Test Support
309
388
 
@@ -314,18 +393,23 @@ require "service_base/rspec"
314
393
  ```
315
394
 
316
395
  ```ruby
317
- stub_service_success(User::CreateService)
318
- stub_service_success(User::CreateService, success: true)
319
- stub_service_success(User::CreateService, success: create(:user))
396
+ stub_service_success(User::CreateService) # yields the success block of the service call, disregarding the Success's value
397
+ stub_service_success(User::CreateService, success: true) # yields the success block of the service call, returning `true` as the Success's value
398
+ stub_service_success(User::CreateService, success: create(:user)) # yields the success block of the service call, returning a `User` instance as the Success's value
399
+ stub_service_success(User::CreateService, success_nil: true) # yields the success block of the service call, returning `nil` as the Success's value
320
400
 
321
401
  stub_service_failure(User::CreateService, failure: "error")
322
- stub_service_failure(User::CreateService failure: :invalid_email, matched: true)
402
+ stub_service_failure(User::CreateService, failure: :invalid_email, matched: true)
323
403
  ```
324
404
 
325
405
  ## Development
326
406
 
327
407
  After checking out the repo, run `bundle install` to install dependencies. Then, run `rspec` to run the tests.
328
408
 
409
+ ### Test Coverage
410
+
411
+ The gem maintains **100%** test coverage across all core components.
412
+
329
413
  ## Contributing
330
414
 
331
415
  Bug reports and pull requests are welcome on GitHub.
@@ -3,14 +3,12 @@
3
3
  module ServiceSupport
4
4
  # Note that you must have at least one `on.success` and one `on.failure`
5
5
  # matcher for each block-style service call
6
- def stub_service_success(service_class, success: nil)
6
+ def stub_service_success(service_class, success: nil, success_nil: false)
7
7
  block = double(:on)
8
8
  allow(block).to receive(:failure)
9
- if success.present?
10
- allow(block).to receive(:success).and_yield(success)
11
- else
12
- allow(block).to receive(:success)
13
- end
9
+
10
+ yield_value = determine_success_yield_value(success, success_nil)
11
+ allow(block).to receive(:success) { |&block_proc| call_with_flexible_params(block_proc, yield_value) }
14
12
  allow(service_class).to receive(:call).and_yield(block)
15
13
  end
16
14
 
@@ -21,16 +19,57 @@ module ServiceSupport
21
19
  def stub_service_failure(service_class, failure:, matched: false)
22
20
  block = double(:on)
23
21
  allow(block).to receive(:success)
24
- if matched # on.failure(:some_error)
25
- allow(block).to receive(:failure) # ignore unmatched on.failure
26
- allow(block).to receive(:failure).with(failure).and_yield(failure)
27
- else # on.failure
28
- # ignore matched on.failure(:some_error)
29
- allow(block).to receive(:failure).with(anything)
30
- allow(block).to receive(:failure).with(no_args).and_yield(failure)
31
- end
22
+
23
+ setup_failure_stub(block, failure, matched)
32
24
  allow(service_class).to receive(:call).and_yield(block)
33
25
  end
26
+
27
+ private
28
+
29
+ def determine_success_yield_value(success, success_nil)
30
+ return success unless success.nil?
31
+ return nil if success_nil
32
+
33
+ :no_yield
34
+ end
35
+
36
+ def call_with_flexible_params(block_proc, yield_value)
37
+ if yield_value == :no_yield
38
+ block_proc.call
39
+ else
40
+ call_block_with_fallback(block_proc, yield_value)
41
+ end
42
+ end
43
+
44
+ def call_block_with_fallback(block_proc, value)
45
+ block_proc.call(value)
46
+ rescue ArgumentError
47
+ # Block doesn't accept parameters, call without arguments
48
+ block_proc.call
49
+ end
50
+
51
+ def setup_failure_stub(block, failure, matched)
52
+ if matched
53
+ setup_matched_failure_stub(block, failure)
54
+ else
55
+ setup_unmatched_failure_stub(block, failure)
56
+ end
57
+ end
58
+
59
+ def setup_matched_failure_stub(block, failure)
60
+ allow(block).to receive(:failure) # ignore unmatched on.failure
61
+ allow(block).to receive(:failure).with(failure) do |&failure_block|
62
+ call_block_with_fallback(failure_block, failure)
63
+ end
64
+ end
65
+
66
+ def setup_unmatched_failure_stub(block, failure)
67
+ # ignore matched on.failure(:some_error)
68
+ allow(block).to receive(:failure).with(anything)
69
+ allow(block).to receive(:failure).with(no_args) do |&failure_block|
70
+ call_block_with_fallback(failure_block, failure)
71
+ end
72
+ end
34
73
  end
35
74
 
36
75
  RSpec.configure do |config|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ServiceBase
4
- VERSION = '1.0.3'
4
+ VERSION = '1.1.0'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: service_base
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Klein
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-04-24 00:00:00.000000000 Z
10
+ date: 2025-09-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dry-matcher
@@ -87,6 +87,7 @@ executables: []
87
87
  extensions: []
88
88
  extra_rdoc_files: []
89
89
  files:
90
+ - CHANGELOG.md
90
91
  - LICENSE.txt
91
92
  - README.md
92
93
  - lib/generators/application_service_generator.rb
@@ -105,7 +106,7 @@ licenses:
105
106
  metadata:
106
107
  homepage_uri: https://github.com/kleinjm/service_base
107
108
  source_code_uri: https://github.com/kleinjm/service_base
108
- changelog_uri: https://github.com/kleinjm/service_base/blob/main/README.md
109
+ changelog_uri: https://github.com/kleinjm/service_base/blob/main/CHANGELOG.md
109
110
  rdoc_options: []
110
111
  require_paths:
111
112
  - lib