service_base 1.0.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +319 -0
- data/lib/service_base/argument_type_annotations.rb +106 -0
- data/lib/service_base/rspec/service_support.rb +38 -0
- data/lib/service_base/rspec.rb +6 -0
- data/lib/service_base/service.rb +116 -0
- data/lib/service_base/types.rb +18 -0
- data/lib/service_base/version.rb +5 -0
- data/lib/service_base.rb +9 -0
- metadata +123 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d20e049647526253612ef6cf134c73ce8ab076c69e9a1f3a5524586f035f8ae3
|
4
|
+
data.tar.gz: 8fe635ae9d66b5358030440828f45e0706a2cad092e7eb45f9738af5c53c9896
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: '0595ddabc674a039b1dfb3189f4f7a6d8aa2e3a6c694391b94c26235180f7b67475685b3c5e19283c07f8b8e141b6c99b109d35659af6ef7d4e436f74ab0a832'
|
7
|
+
data.tar.gz: a38e09b26353baa8a4b37edb97522d33a2bd95a9209f9b930c109f67dbd5d305623c57aa5508c82bdb574150536c37048a5399cb25eed4df5f5c22838456aac4
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Your Name
|
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 all
|
13
|
+
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 THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,319 @@
|
|
1
|
+
# Service Base
|
2
|
+
|
3
|
+
A base service class for Ruby applications that provides common functionality and argument type annotations.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem "service_base"
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
```bash
|
15
|
+
$ bundle install
|
16
|
+
```
|
17
|
+
|
18
|
+
Or install it yourself as:
|
19
|
+
```bash
|
20
|
+
$ gem install service_base
|
21
|
+
```
|
22
|
+
|
23
|
+
# Base Service Pattern
|
24
|
+
|
25
|
+
The general concept of a Service Pattern is useful when a you need to execute a set of
|
26
|
+
sequential steps. The service encapsulates those steps into a single class with a single action to trigger the steps.
|
27
|
+
|
28
|
+
The Base Service Pattern uses a modified [Railway
|
29
|
+
Pattern](https://fsharpforfunandprofit.com/posts/recipe-part2/) set up and enforced by the `Service` class,
|
30
|
+
which every service inherits from.
|
31
|
+
|
32
|
+
## Recommended resources
|
33
|
+
|
34
|
+
- Highly recommended video inspiring this pattern: [Service Objects with
|
35
|
+
Dry.rb](https://www.youtube.com/watch?v=YXiqzHMmv_o)
|
36
|
+
- [Essential
|
37
|
+
RubyOnRails patterns — part 1: Service Objects](https://medium.com/selleo/essential-rubyonrails-patterns-part-1-service-objects-1af9f9573ca1)
|
38
|
+
- [Ruby
|
39
|
+
on Rails pattern: Service Objects](https://dev.to/joker666/ruby-on-rails-pattern-service-objects-b19)
|
40
|
+
|
41
|
+
## Advantages
|
42
|
+
|
43
|
+
- The action of a service should read as a list of steps which makes
|
44
|
+
reading and maintaining the service easy.
|
45
|
+
- Instantiation of a service object allows fine grained control over
|
46
|
+
the arguments being passed in and reduces the need to pass arguments
|
47
|
+
between methods in the same instance.
|
48
|
+
- Encapsulation of logic in a service makes for reusable code, simpler
|
49
|
+
testing, and extracts logic from other objects that should not be
|
50
|
+
responsible for handling that logic.
|
51
|
+
- Verb-naming makes the intention of the service explicit.
|
52
|
+
- Single service actions reveal a single public interface.
|
53
|
+
|
54
|
+
## What defines a service?
|
55
|
+
|
56
|
+
- The main difference between a model and a service is that a model
|
57
|
+
“models” **what** something is while a service lists
|
58
|
+
**how** an action is performed.
|
59
|
+
- A service has a single public method, ie. `call`
|
60
|
+
- A model is a noun, a service is a verb’ed noun that does the one
|
61
|
+
thing the name implies
|
62
|
+
- Ie. `User` (model) versus `User::CreatorService` (service)
|
63
|
+
- Ie. `StripeResponse` (model) versus `PaymentHistoryFetcherService` (service)
|
64
|
+
|
65
|
+
## Naming
|
66
|
+
|
67
|
+
One of the best ways to use the service pattern is for CRUD services - Ie. `ActiveRecordModel` +
|
68
|
+
`::CreateService`, `::UpdateService`,
|
69
|
+
`::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.
|
70
|
+
|
71
|
+
## Returning a Result
|
72
|
+
|
73
|
+
Each service inheriting from BaseService must define
|
74
|
+
`#call` and return a `Success` or
|
75
|
+
`Failure`. These types are `Result` Monads from
|
76
|
+
the dry-monads gem. Both `Result` types may take any value as
|
77
|
+
input, ie. `Success(user)`,
|
78
|
+
`Failure(:not_found)`
|
79
|
+
|
80
|
+
`Failure` can return any value you’d like the caller to have in order to understand the failure.
|
81
|
+
|
82
|
+
The caller of service can unwrap the Success, Failure or like so
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
MyService.call(name: user.name) do |on|
|
86
|
+
on.success { |value| some_method(value) }
|
87
|
+
on.failure { |error| log_error(error) }
|
88
|
+
end
|
89
|
+
```
|
90
|
+
|
91
|
+
To match different expected values of success or failure, pass the
|
92
|
+
value as an argument.
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
MyService.call(name: user.name) do |on|
|
96
|
+
on.success(:created) { notify_created! }
|
97
|
+
on.failure(ActiveRecord::NotFound) { log_not_found }
|
98
|
+
on.failure(:invalid) { render(code: 422) }
|
99
|
+
on.failure { |error| raise(RuntimeError, error) }
|
100
|
+
end
|
101
|
+
```
|
102
|
+
|
103
|
+
Note that you must define both `on.success` and
|
104
|
+
`on.failure` or else an error will be raised in the
|
105
|
+
caller.
|
106
|
+
|
107
|
+
Note that `raise`ing an error requires an error class
|
108
|
+
unless the error itself is an instance of an error class.
|
109
|
+
|
110
|
+
Please see [result](https://dry-rb.org/gems/dry-monads/1.3/result/) for
|
111
|
+
additional mechanisms used for chaining results and handling
|
112
|
+
success/failure values.
|
113
|
+
|
114
|
+
A recommended pattern within services is to return a
|
115
|
+
`Success` and/or `Failure` from each method and
|
116
|
+
yield the result in the caller. This forces you to consider how each
|
117
|
+
method could fail and allows for automatic bubbling up of the
|
118
|
+
`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)
|
119
|
+
|
120
|
+
## Failures vs Exceptions
|
121
|
+
|
122
|
+
Failure = a known error case that may happen and should be gracefully
|
123
|
+
handled
|
124
|
+
|
125
|
+
Raising = an unexpected exception (exceptional circumstances)
|
126
|
+
|
127
|
+
Any call that `raise`s is not rescued by default and will
|
128
|
+
behave as a typical Ruby exception. This is a good thing. We will be
|
129
|
+
alerted when exceptional circumstances arise.
|
130
|
+
|
131
|
+
Return a `Failure` instead when you know of a potential
|
132
|
+
failure case.
|
133
|
+
|
134
|
+
Avoid rescuing major error/exception superclasses such as
|
135
|
+
`StandardError`. Doing so will rescue all subclasses of that
|
136
|
+
error class. If you need to raise an error for control flow, favor a
|
137
|
+
specific error or custom error class.
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
# bad
|
141
|
+
rescue StandardError => e
|
142
|
+
Failure(e)
|
143
|
+
end
|
144
|
+
|
145
|
+
# good - known failure case
|
146
|
+
return Failure("Number #{num} must be positive") if arg.negative?
|
147
|
+
|
148
|
+
# good - exception required for control flow
|
149
|
+
rescue ActiveRecord::Rollback
|
150
|
+
Failure("Record invalid: #{record.inspect}")
|
151
|
+
end
|
152
|
+
```
|
153
|
+
|
154
|
+
## Arguments
|
155
|
+
|
156
|
+
Arguments to a service are defined via the `argument` DSL.
|
157
|
+
The positional name and type arguments are required, the other options
|
158
|
+
are as follows.
|
159
|
+
`argument(:name, String, optional: true, description: "The User's name")`
|
160
|
+
|
161
|
+
If an argument is optional and has a default value, simply set
|
162
|
+
`default: your_value` but do not also specify
|
163
|
+
`optional: true`. Doing so will raise an
|
164
|
+
`ArgumentError`. Additionally, be sure to
|
165
|
+
`.freeze` any mutable default values, ie.
|
166
|
+
`default: {}.freeze`. Failure to do so will raise an
|
167
|
+
`ArgumentError`.
|
168
|
+
|
169
|
+
Empty strings attempted to coerce into integers will throw an error.
|
170
|
+
See [https://github.com/dry-rb/dry-types/issues/344#issuecomment-518743661](https://github.com/dry-rb/dry-types/issues/344#issuecomment-518743661)
|
171
|
+
To instead accept `nil`, do the following:
|
172
|
+
`argument(:some_integer, Params::Nil | Params::Integer)`
|
173
|
+
|
174
|
+
A service should also define a `description`. This is
|
175
|
+
recommended for self-documentation, ie.
|
176
|
+
|
177
|
+
```ruby
|
178
|
+
class MyService < Service
|
179
|
+
description("Does a lot of cool things")
|
180
|
+
end
|
181
|
+
```
|
182
|
+
|
183
|
+
To get the full hash of `argument`s passed into a service,
|
184
|
+
use `attributes`. This is a very useful technique for
|
185
|
+
services that update an object. For example
|
186
|
+
|
187
|
+
```ruby
|
188
|
+
class User::UpdateService < Service
|
189
|
+
def call
|
190
|
+
user.update(attributes)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
```
|
194
|
+
|
195
|
+
### ApplicationRecord Args
|
196
|
+
|
197
|
+
If you add `ApplicationRecord = Types.Instance(ApplicationRecord)` in a Rails project, you can accept any `ApplicationRecord` via
|
198
|
+
|
199
|
+
`argument(:my_record, ApplicationRecord)`
|
200
|
+
|
201
|
+
You can also limit the type of AR record via
|
202
|
+
|
203
|
+
`argument(:my_record, Types.Instance(MyRecord))`
|
204
|
+
|
205
|
+
## Types
|
206
|
+
|
207
|
+
Argument types are defined in Types, which can be extended, ie. app/models/types.rb, and are an extension of [Dry.rb’s
|
208
|
+
Types](https://dry-rb.org/gems/dry-types/1.2/built-in-types/). In order to access constants outside of the dry.rb namespace,
|
209
|
+
or to access a type that collides with one of our defined types, you
|
210
|
+
must include `::` to allow a global constant search.
|
211
|
+
|
212
|
+
Ie. `::ApplicationRecord...`
|
213
|
+
|
214
|
+
`Coercible` and `Params` Types are very
|
215
|
+
powerful and recommended for automatic parsing of inputs, ie. controller
|
216
|
+
parameters.
|
217
|
+
|
218
|
+
For example `argument(:number, Params::Integer)` will
|
219
|
+
convert `"12"` ⇒ `12`
|
220
|
+
|
221
|
+
Entire hash structures may also be validated and automatically
|
222
|
+
parsed, for example:
|
223
|
+
|
224
|
+
```ruby
|
225
|
+
argument(
|
226
|
+
:line_items,
|
227
|
+
Array(
|
228
|
+
Hash.schema(
|
229
|
+
vintage_year: Params::Integer,
|
230
|
+
number_of_credits: Params::Integer,
|
231
|
+
price_dollars_usd: Params::Float,
|
232
|
+
),
|
233
|
+
),
|
234
|
+
```
|
235
|
+
|
236
|
+
## Working with transactions
|
237
|
+
|
238
|
+
⚠️ If your service makes more than one write call to the DB, you
|
239
|
+
should wrap all operations in a single transaction with
|
240
|
+
`::ApplicationRecord.transaction`.
|
241
|
+
|
242
|
+
According to the [Dry
|
243
|
+
RB docs](https://dry-rb.org/gems/dry-monads/1.3/do-notation/#transaction-safety):
|
244
|
+
|
245
|
+
> Under the hood, Do uses exceptions to halt unsuccessful
|
246
|
+
operations…Since yield internally uses exceptions to
|
247
|
+
control the flow, the exception will be detected by
|
248
|
+
the transaction call and the whole operation will be rolled
|
249
|
+
back.
|
250
|
+
>
|
251
|
+
|
252
|
+
Therefore, `yield`ing a `Failure` will roll
|
253
|
+
back the transaction without having to add any explicit exception
|
254
|
+
handling via `rescue`.
|
255
|
+
|
256
|
+
In Rails 7, using `return` inside a transaction [will
|
257
|
+
roll the transaction back](https://www.loyalty.dev/posts/returning-from-transactions-in-rails). Therefore,
|
258
|
+
`return Failure(...)` within a transaction will roll back, as well as `yield`ing a `Failure` within a transaction.
|
259
|
+
|
260
|
+
## Internal Method Result
|
261
|
+
|
262
|
+
The Railway Pattern can be used internally within services via
|
263
|
+
`yield` and `do` notation. This forces the
|
264
|
+
programmer to think about the success and failure cases within each
|
265
|
+
method. See [the dry-monads gem](https://dry-rb.org/gems/dry-monads/1.3/) for more details.
|
266
|
+
|
267
|
+
If the internal methods of the service need to unwrap values, those specific methods need to be registered with the result matcher like so.
|
268
|
+
|
269
|
+
```ruby
|
270
|
+
include Dry::Matcher.for(:method_name, with: Dry::Matcher::ResultMatcher)
|
271
|
+
```
|
272
|
+
|
273
|
+
Within the service, the registered method can then be pattern matched and unwrapped.
|
274
|
+
|
275
|
+
```ruby
|
276
|
+
method_name(order:) do |on|
|
277
|
+
on.success(:deleted) { true }
|
278
|
+
on.success(:cancelled) { destroy_order(order:) }
|
279
|
+
on.failure { |error| raise(RuntimeError, error) }
|
280
|
+
end
|
281
|
+
```
|
282
|
+
|
283
|
+
## Gotchas
|
284
|
+
|
285
|
+
- `yield`ing does not work inside `concerning`
|
286
|
+
blocks or other sub-modules. See [https://github.com/dry-rb/dry-monads/issues/68#issuecomment-1042372398](https://github.com/dry-rb/dry-monads/issues/68#issuecomment-1042372398)
|
287
|
+
|
288
|
+
## Misc
|
289
|
+
|
290
|
+
- To get a pretty printed description of a service and it’s args, run `ServiceClass.pp`
|
291
|
+
|
292
|
+
## Test Support
|
293
|
+
|
294
|
+
The following methods are made available by including the base service testing in your test suite.
|
295
|
+
|
296
|
+
```ruby
|
297
|
+
require "service_base/rspec"
|
298
|
+
```
|
299
|
+
|
300
|
+
```ruby
|
301
|
+
stub_service_success(User::CreateService)
|
302
|
+
stub_service_success(User::CreateService, success: true)
|
303
|
+
stub_service_success(User::CreateService, success: create(:user))
|
304
|
+
|
305
|
+
stub_service_failure(User::CreateService, failure: "error")
|
306
|
+
stub_service_failure(User::CreateService failure: :invalid_email, matched: true)
|
307
|
+
```
|
308
|
+
|
309
|
+
## Development
|
310
|
+
|
311
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `rspec` to run the tests.
|
312
|
+
|
313
|
+
## Contributing
|
314
|
+
|
315
|
+
Bug reports and pull requests are welcome on GitHub.
|
316
|
+
|
317
|
+
## License
|
318
|
+
|
319
|
+
The gem is available as open source under the terms of the MIT License.
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ArgumentTypeAnnotations
|
4
|
+
class << self
|
5
|
+
def extended(klass)
|
6
|
+
if !klass.is_a?(Class) || !klass.ancestors.include?(Dry::Struct)
|
7
|
+
raise(TypeError, "#{name} should be extended on a Dry::Struct subclass")
|
8
|
+
end
|
9
|
+
|
10
|
+
# `Types` overrides default types to help shorthand Type::String.
|
11
|
+
# To access Ruby's native types within a service, use `::`, ie. `::String`
|
12
|
+
klass.include(Types)
|
13
|
+
end
|
14
|
+
|
15
|
+
def included(klass)
|
16
|
+
if !klass.singleton_class? || !klass.attached_object.ancestors.include?(Dry::Struct)
|
17
|
+
raise(TypeError, "#{name} should be included on the singleton class of a Dry::Struct subclass")
|
18
|
+
end
|
19
|
+
|
20
|
+
# `Types` overrides default types to help shorthand Type::String.
|
21
|
+
# To access Ruby's native types within a service, use `::`, ie. `::String`
|
22
|
+
klass.attached_object.include(Types)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Defines an argument using the ServiceBase DSL.
|
27
|
+
# Under the hood, this uses dry-struct's attribute DSL.
|
28
|
+
def argument(name, type, configuration = {}, &)
|
29
|
+
description = configuration[:description] == "" ? nil : configuration[:description]
|
30
|
+
type = type.meta(description:)
|
31
|
+
|
32
|
+
default = configuration[:default]
|
33
|
+
validate_frozen_default!(name:, default:)
|
34
|
+
|
35
|
+
optional = configuration.fetch(:optional, false)
|
36
|
+
validate_optional_or_default!(optional:, default:, name:)
|
37
|
+
|
38
|
+
type = set_default(type:, default:)
|
39
|
+
|
40
|
+
if optional
|
41
|
+
# attribute? allows the key to be omitted.
|
42
|
+
# .optional allows the value to be nil.
|
43
|
+
# https://dry-rb.org/gems/dry-types/1.2/optional-values/
|
44
|
+
# https://github.com/dry-rb/dry-struct/blob/master/lib/dry/struct/class_interface.rb#L141-L169
|
45
|
+
attribute?(name, type.optional, &)
|
46
|
+
else
|
47
|
+
# https://github.com/dry-rb/dry-struct/blob/master/lib/dry/struct/class_interface.rb#L30-L104
|
48
|
+
attribute(name, type, &)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
# Raises a warning from dry-types to avoid memory sharing.
|
55
|
+
# https://github.com/dry-rb/dry-types/blob/master/lib/dry/types/builder.rb#L71-L81
|
56
|
+
def validate_frozen_default!(name:, default:)
|
57
|
+
return if default.frozen?
|
58
|
+
|
59
|
+
raise(
|
60
|
+
ArgumentError,
|
61
|
+
"#{default} provided as a default value for #{name} is mutable. " \
|
62
|
+
"Please `.freeze` your `default:` input.",
|
63
|
+
)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Do not allow setting both a default value and optional: true. If both
|
67
|
+
# are specified, the default will not be used.
|
68
|
+
def validate_optional_or_default!(optional:, default:, name:)
|
69
|
+
return unless optional && !default.nil?
|
70
|
+
|
71
|
+
raise(
|
72
|
+
ArgumentError,
|
73
|
+
"#{name} cannot specify both a default value and optional: true. " \
|
74
|
+
"Only specify a default value if the value is optional.",
|
75
|
+
)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Ensures that provided args are declared as `argument`s
|
79
|
+
def validate_args!(args:)
|
80
|
+
invalid_args = (args.keys - attribute_names)
|
81
|
+
return if invalid_args.empty?
|
82
|
+
|
83
|
+
raise(
|
84
|
+
ArgumentError,
|
85
|
+
"#{self} provided invalid arguments: #{invalid_args.join(', ')}",
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Sets the default value on the type.
|
90
|
+
# For primitive types, the default can be set after initialization.
|
91
|
+
# For enums, the default must be set during initialization. Therefore,
|
92
|
+
# we must check the type of the enum and then reconstruct the enum with
|
93
|
+
# the default value being set.
|
94
|
+
# See "Note" in https://dry-rb.org/gems/dry-types/1.2/enum/
|
95
|
+
def set_default(type:, default:)
|
96
|
+
return type if default.nil?
|
97
|
+
|
98
|
+
if type.is_a?(Dry::Types::Enum)
|
99
|
+
values = type.values
|
100
|
+
type_class = "Types::#{values.first.class}".constantize
|
101
|
+
type_class.default(default).enum(*values)
|
102
|
+
else
|
103
|
+
type.default(default)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ServiceSupport
|
4
|
+
# Note that you must have at least one `on.success` and one `on.failure`
|
5
|
+
# matcher for each block-style service call
|
6
|
+
def stub_service_success(service_class, success: nil)
|
7
|
+
block = double(:on)
|
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
|
14
|
+
allow(service_class).to receive(:call).and_yield(block)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Note that you must have at least one `on.success` and one `on.failure`
|
18
|
+
# matcher for each block-style service call.
|
19
|
+
# Set `matched: true` for specific `on.failure(:error)` blocks.
|
20
|
+
# Set `matched: true` for catch-all `on.failure` blocks.
|
21
|
+
def stub_service_failure(service_class, failure:, matched: false)
|
22
|
+
block = double(:on)
|
23
|
+
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
|
32
|
+
allow(service_class).to receive(:call).and_yield(block)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
RSpec.configure do |config|
|
37
|
+
config.include ServiceSupport
|
38
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-matcher'
|
4
|
+
require 'dry-struct'
|
5
|
+
require 'dry/matcher/result_matcher'
|
6
|
+
require 'dry/monads'
|
7
|
+
require 'dry/monads/do'
|
8
|
+
require 'memery'
|
9
|
+
|
10
|
+
class Service < Dry::Struct
|
11
|
+
extend Dry::Monads::Result::Mixin::Constructors
|
12
|
+
include Dry::Monads::Do.for(:call)
|
13
|
+
include Dry::Monads[:result, :do]
|
14
|
+
|
15
|
+
extend ArgumentTypeAnnotations
|
16
|
+
include Memery
|
17
|
+
|
18
|
+
class ServiceNotSuccessful < StandardError
|
19
|
+
attr_reader(:failure)
|
20
|
+
|
21
|
+
def initialize(failure)
|
22
|
+
super('Failed to call service')
|
23
|
+
@failure = failure
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class << self
|
28
|
+
# The public class call method.
|
29
|
+
#
|
30
|
+
# The default empty hash is important to prevent an argument error when
|
31
|
+
# passing no arguments to a service that defines defaults for every argument.
|
32
|
+
def call(args = {}, &block)
|
33
|
+
validate_args!(args: args)
|
34
|
+
|
35
|
+
result = new(args).call
|
36
|
+
match_result(result, &block)
|
37
|
+
end
|
38
|
+
|
39
|
+
def call!(*args)
|
40
|
+
result = call(*args)
|
41
|
+
raise(ServiceNotSuccessful, result.failure) if result.failure?
|
42
|
+
|
43
|
+
result
|
44
|
+
end
|
45
|
+
|
46
|
+
# Pretty prints (pp) the description of the service, ie. `MyService.pp`
|
47
|
+
def pp
|
48
|
+
logger = Logger.new($stdout)
|
49
|
+
logger.info("#{name}: #{service_description}")
|
50
|
+
logger.info('Arguments')
|
51
|
+
|
52
|
+
schema_definition.each do |arg|
|
53
|
+
logger.info(" #{arg[:name]} (#{arg[:type]}): #{arg[:description]}")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# @description getter
|
58
|
+
def service_description
|
59
|
+
@description || 'No description'
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
# Set the description on the service
|
65
|
+
def description(text)
|
66
|
+
@description = text
|
67
|
+
end
|
68
|
+
|
69
|
+
# Employs ResultMatcher to unwrap values using `on.success` & `on.failure`
|
70
|
+
# syntax. If not using block form to extract the result of a service,
|
71
|
+
# ie. `MyService.call.fmap { |result| result + 2 }`, ensure you explictly
|
72
|
+
# handle Failures. See https://dry-rb.org/gems/dry-monads/1.3/result/
|
73
|
+
def match_result(result, &block)
|
74
|
+
# https://medium.com/swlh/better-rails-service-objects-with-dry-rb-702687394e3d
|
75
|
+
if block
|
76
|
+
# raises Dry::Matcher::NonExhaustiveMatchError: cases +failure+ not handled
|
77
|
+
# if `on.failure` is not declared
|
78
|
+
Dry::Matcher::ResultMatcher.call(result, &block)
|
79
|
+
else
|
80
|
+
result
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Introspects the arguments DSL to extract information on each argument
|
85
|
+
def schema_definition
|
86
|
+
attribute_names.each_with_object([]) do |attribute_name, list|
|
87
|
+
dry_type = schema.key(attribute_name)
|
88
|
+
list << {
|
89
|
+
name: attribute_name,
|
90
|
+
description: dry_type.meta[:description],
|
91
|
+
type: dry_type.type.name
|
92
|
+
}
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
# The call method that must be defined by every inheriting service class
|
100
|
+
def call
|
101
|
+
raise(NotImplementedError)
|
102
|
+
end
|
103
|
+
|
104
|
+
# A locale lookup helper that uses the name of the service
|
105
|
+
def locale(selector, args = {})
|
106
|
+
class_name = self.class.name.gsub('::', '.').underscore
|
107
|
+
I18n.t(".#{selector}", scope: "services.#{class_name}", **args)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Structured Monad Result Failure type for returning a ResponseError
|
111
|
+
class ResponseFailure < Dry::Monads::Result::Failure
|
112
|
+
def initialize(message, code, trace = Dry::Monads::RightBiased::Left.trace_caller)
|
113
|
+
super(ResponseError.new(message: message, code: code), trace)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,18 @@
|
|
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
|
+
require 'dry-types'
|
12
|
+
|
13
|
+
module Types
|
14
|
+
include Dry.Types()
|
15
|
+
|
16
|
+
UpCasedString = Types::String.constructor(&:upcase)
|
17
|
+
Boolean = Bool # alias the built in type, Bool
|
18
|
+
end
|
data/lib/service_base.rb
ADDED
metadata
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: service_base
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- James Klein
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 2025-04-01 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: dry-matcher
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: 0.8.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.8.0
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: dry-monads
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '1.6'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '1.6'
|
40
|
+
- !ruby/object:Gem::Dependency
|
41
|
+
name: dry-struct
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '1.6'
|
47
|
+
type: :runtime
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '1.6'
|
54
|
+
- !ruby/object:Gem::Dependency
|
55
|
+
name: dry-types
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '1.7'
|
61
|
+
type: :runtime
|
62
|
+
prerelease: false
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '1.7'
|
68
|
+
- !ruby/object:Gem::Dependency
|
69
|
+
name: memery
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '1.7'
|
75
|
+
type: :runtime
|
76
|
+
prerelease: false
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '1.7'
|
82
|
+
description: A base service class for Ruby applications with argument type annotations
|
83
|
+
and railway-oriented programming
|
84
|
+
email:
|
85
|
+
- kleinjm007@gmail.com
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- LICENSE.txt
|
91
|
+
- README.md
|
92
|
+
- lib/service_base.rb
|
93
|
+
- lib/service_base/argument_type_annotations.rb
|
94
|
+
- lib/service_base/rspec.rb
|
95
|
+
- lib/service_base/rspec/service_support.rb
|
96
|
+
- lib/service_base/service.rb
|
97
|
+
- lib/service_base/types.rb
|
98
|
+
- lib/service_base/version.rb
|
99
|
+
homepage: https://github.com/kleinjm/service_base
|
100
|
+
licenses:
|
101
|
+
- MIT
|
102
|
+
metadata:
|
103
|
+
homepage_uri: https://github.com/kleinjm/service_base
|
104
|
+
source_code_uri: https://github.com/kleinjm/service_base
|
105
|
+
changelog_uri: https://github.com/kleinjm/service_base/blob/main/README.md
|
106
|
+
rdoc_options: []
|
107
|
+
require_paths:
|
108
|
+
- lib
|
109
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
110
|
+
requirements:
|
111
|
+
- - ">="
|
112
|
+
- !ruby/object:Gem::Version
|
113
|
+
version: 2.6.0
|
114
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
115
|
+
requirements:
|
116
|
+
- - ">="
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: '0'
|
119
|
+
requirements: []
|
120
|
+
rubygems_version: 3.6.2
|
121
|
+
specification_version: 4
|
122
|
+
summary: A base service class for Ruby applications
|
123
|
+
test_files: []
|