service_base 1.0.1 → 1.0.3

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: b9c9ee7f7424ddd348b82d0708fa79703e7869771284b7018f7e3cb41828770b
4
- data.tar.gz: 92032edd435bc81747cbd0a15670e3210716cefd51d8fc7598f7fca5194f5127
3
+ metadata.gz: 17af4f8d7a5f86ee24adbbbba2118e7b47134f2730e4e04fb0c0107c86cfa168
4
+ data.tar.gz: 1ad5a04d75c99bbc061d1ab53ce67d0d8864c132b8be85ebeb4da3b1f759d996
5
5
  SHA512:
6
- metadata.gz: a95dd087bca43238c0ed3e6c94773f61e05758def00c38e9ac4bfd0f258f0eb3514092e404c9f1fd14a9dc47f6f08b4529869d8fa5e489967848396b57347910
7
- data.tar.gz: a524e1751b728e6cce6fd723c78b3f5ccb7369e8db0f27a412b42d9be6d4cb35a17e7b6926bae291300ed56e5585b5a97b12dea4b3fdc5efa01f88c86a8be007
6
+ metadata.gz: 559196375ec8a48520b076feb989287c43e84cf7a759b91d0db753448f9199fe02f3febff0a06e9d797523f5774e58d26e4745f921820f94298519873d4a5732
7
+ data.tar.gz: ec37a4775094ebf67fa9e09b3db800206f31f8cfa5775866675f0213f9f188f5b228bc503e6e2ff7b3827bcfae039cc1dc0285796e96831ef990f182174a5709
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Service Base
2
2
 
3
- A base service class for Ruby applications that provides common functionality and argument type annotations.
3
+ A base service class for Ruby applications that provides common functionality and argument type annotations DSL.
4
+
5
+ ## Dependencies
6
+
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.
4
8
 
5
9
  ## Installation
6
10
 
@@ -11,18 +15,23 @@ gem "service_base"
11
15
  ```
12
16
 
13
17
  And then execute:
14
- ```bash
18
+ ```sh
15
19
  $ bundle install
16
20
  ```
17
21
 
18
22
  Or install it yourself as:
19
- ```bash
23
+ ```sh
20
24
  $ gem install service_base
21
25
  ```
22
26
 
23
- To follow convention in a Rails application, it's recommended that you create an `ApplicationService` subclass.
27
+ For Rails projects, then run:
28
+ ```sh
29
+ rails g service_base:install
30
+ ```
24
31
 
32
+ Installing the gem in a Rails project will create an `ApplicationService` subclass, following Rails conventions.
25
33
  ```rb
34
+ # app/services/application_service.rb
26
35
  class ApplicationService < ServiceBase::Service
27
36
  end
28
37
  ```
@@ -47,46 +56,37 @@ on Rails pattern: Service Objects](https://dev.to/joker666/ruby-on-rails-pattern
47
56
 
48
57
  ## Advantages
49
58
 
50
- - The action of a service should read as a list of steps which makes
51
- reading and maintaining the service easy.
59
+ - The action of a service should read as a list of steps which makes reading and maintaining the service easy.
52
60
  - Instantiation of a service object allows fine grained control over
53
61
  the arguments being passed in and reduces the need to pass arguments
54
62
  between methods in the same instance.
55
63
  - Encapsulation of logic in a service makes for reusable code, simpler
56
64
  testing, and extracts logic from other objects that should not be
57
65
  responsible for handling that logic.
66
+ - Removes the need for ActiveRecord callbacks and consolidates logic of related models into one place in the codebase.
58
67
  - Verb-naming makes the intention of the service explicit.
59
68
  - Single service actions reveal a single public interface.
60
69
 
61
70
  ## What defines a service?
62
71
 
63
- - The main difference between a model and a service is that a model
64
- “models” **what** something is while a service lists
65
- **how** an action is performed.
72
+ - The main difference between a model and a service is that a model “models” **what** something is while a service lists **how** an action is performed.
66
73
  - A service has a single public method, ie. `call`
67
- - A model is a noun, a service is a verb’ed noun that does the one
68
- thing the name implies
69
- - Ie. `User` (model) versus `User::CreatorService` (service)
70
- - Ie. `StripeResponse` (model) versus `PaymentHistoryFetcherService` (service)
74
+ - A model is a noun, a service is a verb or verb’ed noun that does the one thing the name implies
75
+ - Ie. `User` (model) versus `User::CreatorService` (service)
76
+ - Ie. `StripeResponse` (model) versus `PaymentHistoryFetcherService` (service)
71
77
 
72
78
  ## Naming
73
79
 
74
- One of the best ways to use the service pattern is for CRUD services - Ie. `ActiveRecordModel` +
75
- `::CreateService`, `::UpdateService`,
76
- `::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.
80
+ 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.
77
81
 
78
82
  ## Returning a Result
79
83
 
80
- Each service inheriting from BaseService must define
81
- `#call` and return a `Success` or
82
- `Failure`. These types are `Result` Monads from
83
- the dry-monads gem. Both `Result` types may take any value as
84
- input, ie. `Success(user)`,
85
- `Failure(:not_found)`
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)`
86
86
 
87
87
  `Failure` can return any value you’d like the caller to have in order to understand the failure.
88
88
 
89
- The caller of service can unwrap the Success, Failure or like so
89
+ The caller of service can unwrap the `Success` or `Failure`.
90
90
 
91
91
  ```ruby
92
92
  MyService.call(name: user.name) do |on|
@@ -95,8 +95,7 @@ MyService.call(name: user.name) do |on|
95
95
  end
96
96
  ```
97
97
 
98
- To match different expected values of success or failure, pass the
99
- value as an argument.
98
+ To match different expected values of success or failure, pass the value as an argument when unwrapping it.
100
99
 
101
100
  ```ruby
102
101
  MyService.call(name: user.name) do |on|
@@ -107,36 +106,23 @@ MyService.call(name: user.name) do |on|
107
106
  end
108
107
  ```
109
108
 
110
- Note that you must define both `on.success` and
111
- `on.failure` or else an error will be raised in the
112
- caller.
109
+ Note that you must define both `on.success` and `on.failure` or else an error will be raised in the caller.
113
110
 
114
- Note that `raise`ing an error requires an error class
115
- unless the error itself is an instance of an error class.
111
+ Note that `raise`ing an error requires an error class unless the error itself is an instance of an error class.
116
112
 
117
- Please see [result](https://dry-rb.org/gems/dry-monads/1.3/result/) for
118
- additional mechanisms used for chaining results and handling
119
- success/failure values.
120
-
121
- A recommended pattern within services is to return a
122
- `Success` and/or `Failure` from each method and
123
- yield the result in the caller. This forces you to consider how each
124
- method could fail and allows for automatic bubbling up of the
125
- `Failure` via railway-style programming. Examples at [https://dry-rb.org/gems/dry-monads/1.3/do-notation/#adding-batteries](https://dry-rb.org/gems/dry-monads/1.3/do-notation/#adding-batteries)
113
+ Please see [result](https://dry-rb.org/gems/dry-monads/1.3/result/) for additional mechanisms used for chaining results and handling success/failure values.
126
114
 
127
115
  ## Failures vs Exceptions
128
116
 
129
- Failure = a known error case that may happen and should be gracefully
130
- handled
117
+ Failure = a known error case that may happen and should be gracefully handled
131
118
 
132
- Raising = an unexpected exception (exceptional circumstances)
119
+ Raising = an **unexpected** exception (exceptional circumstances)
133
120
 
134
121
  Any call that `raise`s is not rescued by default and will
135
- behave as a typical Ruby exception. This is a good thing. We will be
122
+ behave as a typical Ruby exception. This is a good thing. You will be
136
123
  alerted when exceptional circumstances arise.
137
124
 
138
- Return a `Failure` instead when you know of a potential
139
- failure case.
125
+ Return a `Failure` instead when you know of a potential failure case.
140
126
 
141
127
  Avoid rescuing major error/exception superclasses such as
142
128
  `StandardError`. Doing so will rescue all subclasses of that
@@ -161,81 +147,104 @@ end
161
147
  ## Arguments
162
148
 
163
149
  Arguments to a service are defined via the `argument` DSL.
164
- The positional name and type arguments are required, the other options
165
- are as follows.
166
- `argument(:name, String, optional: true, description: "The User's name")`
167
-
168
- If an argument is optional and has a default value, simply set
169
- `default: your_value` but do not also specify
170
- `optional: true`. Doing so will raise an
171
- `ArgumentError`. Additionally, be sure to
172
- `.freeze` any mutable default values, ie.
173
- `default: {}.freeze`. Failure to do so will raise an
174
- `ArgumentError`.
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")`
175
152
 
176
- Empty strings attempted to coerce into integers will throw an error.
177
- See [https://github.com/dry-rb/dry-types/issues/344#issuecomment-518743661](https://github.com/dry-rb/dry-types/issues/344#issuecomment-518743661)
178
- To instead accept `nil`, do the following:
179
- `argument(:some_integer, Params::Nil | Params::Integer)`
153
+ If an argument is optional and has a default value, simply set `default: your_value` but do not also specify `optional: true`.
154
+ Doing so will raise an `ArgumentError`.
155
+
156
+ Additionally, be sure to `.freeze` any mutable default values, ie. `default: {}.freeze`.
157
+ Failure to do so will raise an `ArgumentError`.
158
+
159
+ To allow multiple types as arguments, use `|`. For example,
160
+
161
+ ```rb
162
+ argument(:value, Type::String | Type::Integer)
163
+ ```
180
164
 
181
- A service should also define a `description`. This is
182
- recommended for self-documentation, ie.
165
+ A service should also define a `description`. This is recommended for self-documentation, ie.
183
166
 
184
167
  ```ruby
185
- class MyService < ServiceBase::Service
168
+ class MyService < ApplicationService
186
169
  description("Does a lot of cool things")
187
170
  end
188
171
  ```
189
172
 
190
- To get the full hash of `argument`s passed into a service,
191
- use `attributes`. This is a very useful technique for
192
- services that update an object. For example
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
193
175
 
194
176
  ```ruby
195
- class User::UpdateService < ServiceBase::Service
177
+ class User::UpdateService < ApplicationService
178
+ argument(:name, String)
179
+
196
180
  def call
197
- user.update(attributes)
181
+ user.update(arguments)
198
182
  end
199
183
  end
200
184
  ```
201
185
 
202
- ### ApplicationRecord Args
186
+ ### Nil
203
187
 
204
- If you add `ApplicationRecord = Types.Instance(ApplicationRecord)` in a Rails project, you can accept any `ApplicationRecord` via
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)`
205
192
 
206
- `argument(:my_record, ApplicationRecord)`
207
193
 
208
- You can also limit the type of AR record via
194
+ ## Types
209
195
 
210
- `argument(:my_record, Types.Instance(MyRecord))`
196
+ Argument types come from, [Dry.rb’s Types](https://dry-rb.org/gems/dry-types/1.2/built-in-types/), which can be extended.
197
+ You may also add custom types as outlined in [Dry.rb Custom Types](https://dry-rb.org/gems/dry-types/1.2/custom-types/).
211
198
 
212
- ## Types
199
+ 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.
213
200
 
214
- Argument types are defined in Types, which can be extended, ie. app/models/types.rb, and are an extension of [Dry.rb’s
215
- Types](https://dry-rb.org/gems/dry-types/1.2/built-in-types/). In order to access constants outside of the dry.rb namespace,
216
- or to access a type that collides with one of our defined types, you
217
- must include `::` to allow a global constant search.
201
+ ```rb
202
+ # app/models/type.rb
203
+ module Type
204
+ include ServiceBase::Types
205
+
206
+ # Any ApplicationRecord subclass
207
+ ApplicationRecord = Dry.Types.Instance(ApplicationRecord)
208
+ User = Dry.Types.Instance(User)
209
+ Project = Dry.Types.Instance(Project)
210
+
211
+ # Controller params are an ActionController::Parameters instance or a hash (easier for testing)
212
+ ControllerParams = Dry.Types.Instance(ActionController::Parameters) | Dry.Types.Instance(Hash)
213
+
214
+ # Customer param hashes
215
+ AddressParams = Dry::Types['hash'].schema(
216
+ address: Dry::Types['string'],
217
+ address2: Dry::Types['string'],
218
+ city: Dry::Types['string'],
219
+ state: Dry::Types['string'],
220
+ zip: Dry::Types['string']
221
+ )
222
+ end
218
223
 
219
- Ie. `::ApplicationRecord...`
224
+ # app/services/example_service.rb
225
+ 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")
231
+ end
232
+ ```
220
233
 
221
- `Coercible` and `Params` Types are very
222
- powerful and recommended for automatic parsing of inputs, ie. controller
223
- parameters.
234
+ Dry.rb's `Coercible` and `Params` Types are very powerful and recommended for automatic parsing of inputs, ie. controller parameters.
224
235
 
225
- For example `argument(:number, Params::Integer)` will
226
- convert `"12"` ⇒ `12`
236
+ For example `argument(:number, Type::Params::Integer)` will convert `"12"` ⇒ `12`.
227
237
 
228
- Entire hash structures may also be validated and automatically
229
- parsed, for example:
238
+ Entire hash structures may also be validated and automatically parsed, for example:
230
239
 
231
240
  ```ruby
232
241
  argument(
233
242
  :line_items,
234
- Array(
235
- Hash.schema(
236
- vintage_year: Params::Integer,
237
- number_of_credits: Params::Integer,
238
- price_dollars_usd: Params::Float,
243
+ Type::Array(
244
+ Type::Hash.schema(
245
+ vintage_year: Type::Params::Integer,
246
+ number_of_credits: Type::Params::Integer,
247
+ price_dollars_usd: Type::Params::Float,
239
248
  ),
240
249
  ),
241
250
  ```
@@ -266,10 +275,10 @@ roll the transaction back](https://www.loyalty.dev/posts/returning-from-transact
266
275
 
267
276
  ## Internal Method Result
268
277
 
269
- The Railway Pattern can be used internally within services via
270
- `yield` and `do` notation. This forces the
271
- programmer to think about the success and failure cases within each
272
- method. See [the dry-monads gem](https://dry-rb.org/gems/dry-monads/1.3/) for more details.
278
+ A recommended pattern within services is to return a `Success` and/or `Failure` from each method and
279
+ `yield` the result in the caller. This forces you to consider how each
280
+ method could fail and allows for automatic bubbling up of the
281
+ `Failure` via railway-style programming. Examples at [https://dry-rb.org/gems/dry-monads/1.3/do-notation/#adding-batteries](https://dry-rb.org/gems/dry-monads/1.3/do-notation/#adding-batteries)
273
282
 
274
283
  If the internal methods of the service need to unwrap values, those specific methods need to be registered with the result matcher like so.
275
284
 
@@ -323,4 +332,4 @@ Bug reports and pull requests are welcome on GitHub.
323
332
 
324
333
  ## License
325
334
 
326
- The gem is available as open source under the terms of the MIT License.
335
+ The gem is available as open source under the terms of the MIT License.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationServiceGenerator < Rails::Generators::Base
4
+ source_root File.expand_path('templates', __dir__)
5
+
6
+ def create_application_service_file
7
+ service_path = 'app/services/application_service.rb'
8
+
9
+ if File.exist?(service_path)
10
+ # File exists, check if it needs to be updated
11
+ content = File.read(service_path)
12
+
13
+ unless content.include?('class ApplicationService < ServiceBase::Service')
14
+ # Update the class definition
15
+ new_content = content.sub(/class ApplicationService.*\n/, "class ApplicationService < ServiceBase::Service\n")
16
+ File.write(service_path, new_content)
17
+ end
18
+ else
19
+ # Create new file with template
20
+ create_file service_path, <<~RUBY
21
+ class ApplicationService < ServiceBase::Service
22
+ end
23
+ RUBY
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceBase
4
+ class InstallGenerator < Rails::Generators::Base
5
+ source_root File.expand_path('templates', __dir__)
6
+
7
+ def run_generators
8
+ generate 'application_service'
9
+ generate 'types'
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TypesGenerator < Rails::Generators::Base
4
+ source_root File.expand_path('templates', __dir__)
5
+
6
+ def create_types_file
7
+ types_path = 'app/models/type.rb'
8
+
9
+ if File.exist?(types_path)
10
+ # File exists, check if it needs the include statement
11
+ content = File.read(types_path)
12
+
13
+ unless content.include?('include ServiceBase::Types')
14
+ # Add include statement at the top of the module
15
+ new_content = content.sub(/module Type\s*\n/, "module Type\n include ServiceBase::Types\n")
16
+ File.write(types_path, new_content)
17
+ end
18
+ else
19
+ # Create new file with template
20
+ create_file types_path, <<~RUBY
21
+ module Type
22
+ include ServiceBase::Types
23
+ end
24
+ RUBY
25
+ end
26
+ end
27
+ end
@@ -7,10 +7,6 @@ module ServiceBase
7
7
  if !klass.is_a?(Class) || !klass.ancestors.include?(Dry::Struct)
8
8
  raise(TypeError, "#{name} should be extended on a Dry::Struct subclass")
9
9
  end
10
-
11
- # `Types` overrides default types to help shorthand Type::String.
12
- # To access Ruby's native types within a service, use `::`, ie. `::String`
13
- klass.include(Types)
14
10
  end
15
11
 
16
12
  def included(klass)
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry-matcher'
4
- require 'dry-struct'
5
- require 'dry/matcher/result_matcher'
6
3
  require 'dry/monads'
7
4
  require 'dry/monads/do'
5
+ require 'dry/matcher/result_matcher'
6
+ require 'dry-struct'
7
+ require 'dry-matcher'
8
8
  require 'memery'
9
9
 
10
10
  module ServiceBase
@@ -95,24 +95,16 @@ module ServiceBase
95
95
  end
96
96
  end
97
97
 
98
+ # Returns a hash of all arguments and their values
99
+ def arguments
100
+ attributes
101
+ end
102
+
98
103
  private
99
104
 
100
105
  # The call method that must be defined by every inheriting service class
101
106
  def call
102
107
  raise(NotImplementedError)
103
108
  end
104
-
105
- # A locale lookup helper that uses the name of the service
106
- def locale(selector, args = {})
107
- class_name = self.class.name.gsub('::', '.').underscore
108
- I18n.t(".#{selector}", scope: "services.#{class_name}", **args)
109
- end
110
-
111
- # Structured Monad Result Failure type for returning a ResponseError
112
- class ResponseFailure < Dry::Monads::Result::Failure
113
- def initialize(message, code, trace = Dry::Monads::RightBiased::Left.trace_caller)
114
- super(ResponseError.new(message: message, code: code), trace)
115
- end
116
- end
117
109
  end
118
110
  end
@@ -1,20 +1,14 @@
1
- # frozen_string_literal: true
2
-
3
- # Defines Dry Types. These Types are included in the ServiceBase for type
4
- # enforcement when defining `argument`s.
5
- #
6
- # For example, you may want to add `ApplicationRecord = Types.Instance(ApplicationRecord)`
7
- #
8
- # Add custom types as outlined in
9
- # https://dry-rb.org/gems/dry-types/1.2/custom-types/
10
-
11
1
  require 'dry-types'
12
2
 
13
3
  module ServiceBase
14
4
  module Types
15
5
  include Dry.Types()
16
6
 
17
- UpCasedString = Types::String.constructor(&:upcase)
18
- Boolean = Bool # alias the built in type, Bool
7
+ def self.included(base)
8
+ constants.each { |constant| base.const_set(constant, const_get("#{self}::#{constant}")) }
9
+ end
10
+
11
+ # Alias Bool -> Boolean
12
+ Boolean = Dry::Types['bool']
19
13
  end
20
14
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ServiceBase
4
- VERSION = '1.0.1'
4
+ VERSION = '1.0.3'
5
5
  end
data/lib/service_base.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'service_base/version'
4
- require_relative 'service_base/types'
5
4
  require_relative 'service_base/argument_type_annotations'
6
5
  require_relative 'service_base/service'
6
+ require_relative 'service_base/types'
7
7
 
8
8
  module ServiceBase
9
9
  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.1
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Klein
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-04-02 00:00:00.000000000 Z
10
+ date: 2025-04-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dry-matcher
@@ -89,6 +89,9 @@ extra_rdoc_files: []
89
89
  files:
90
90
  - LICENSE.txt
91
91
  - README.md
92
+ - lib/generators/application_service_generator.rb
93
+ - lib/generators/service_base/install_generator.rb
94
+ - lib/generators/types_generator.rb
92
95
  - lib/service_base.rb
93
96
  - lib/service_base/argument_type_annotations.rb
94
97
  - lib/service_base/rspec.rb