service_base 1.0.0 → 1.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 +4 -4
- data/README.md +9 -2
- data/lib/service_base/argument_type_annotations.rb +88 -86
- data/lib/service_base/service.rb +83 -81
- data/lib/service_base/types.rb +6 -4
- data/lib/service_base/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b9c9ee7f7424ddd348b82d0708fa79703e7869771284b7018f7e3cb41828770b
|
4
|
+
data.tar.gz: 92032edd435bc81747cbd0a15670e3210716cefd51d8fc7598f7fca5194f5127
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a95dd087bca43238c0ed3e6c94773f61e05758def00c38e9ac4bfd0f258f0eb3514092e404c9f1fd14a9dc47f6f08b4529869d8fa5e489967848396b57347910
|
7
|
+
data.tar.gz: a524e1751b728e6cce6fd723c78b3f5ccb7369e8db0f27a412b42d9be6d4cb35a17e7b6926bae291300ed56e5585b5a97b12dea4b3fdc5efa01f88c86a8be007
|
data/README.md
CHANGED
@@ -20,6 +20,13 @@ Or install it yourself as:
|
|
20
20
|
$ gem install service_base
|
21
21
|
```
|
22
22
|
|
23
|
+
To follow convention in a Rails application, it's recommended that you create an `ApplicationService` subclass.
|
24
|
+
|
25
|
+
```rb
|
26
|
+
class ApplicationService < ServiceBase::Service
|
27
|
+
end
|
28
|
+
```
|
29
|
+
|
23
30
|
# Base Service Pattern
|
24
31
|
|
25
32
|
The general concept of a Service Pattern is useful when a you need to execute a set of
|
@@ -175,7 +182,7 @@ A service should also define a `description`. This is
|
|
175
182
|
recommended for self-documentation, ie.
|
176
183
|
|
177
184
|
```ruby
|
178
|
-
class MyService < Service
|
185
|
+
class MyService < ServiceBase::Service
|
179
186
|
description("Does a lot of cool things")
|
180
187
|
end
|
181
188
|
```
|
@@ -185,7 +192,7 @@ use `attributes`. This is a very useful technique for
|
|
185
192
|
services that update an object. For example
|
186
193
|
|
187
194
|
```ruby
|
188
|
-
class User::UpdateService < Service
|
195
|
+
class User::UpdateService < ServiceBase::Service
|
189
196
|
def call
|
190
197
|
user.update(attributes)
|
191
198
|
end
|
@@ -1,106 +1,108 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
3
|
+
module ServiceBase
|
4
|
+
module ArgumentTypeAnnotations
|
5
|
+
class << self
|
6
|
+
def extended(klass)
|
7
|
+
if !klass.is_a?(Class) || !klass.ancestors.include?(Dry::Struct)
|
8
|
+
raise(TypeError, "#{name} should be extended on a Dry::Struct subclass")
|
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)
|
8
14
|
end
|
9
15
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
16
|
+
def included(klass)
|
17
|
+
if !klass.singleton_class? || !klass.attached_object.ancestors.include?(Dry::Struct)
|
18
|
+
raise(TypeError, "#{name} should be included on the singleton class of a Dry::Struct subclass")
|
19
|
+
end
|
14
20
|
|
15
|
-
|
16
|
-
|
17
|
-
|
21
|
+
# `Types` overrides default types to help shorthand Type::String.
|
22
|
+
# To access Ruby's native types within a service, use `::`, ie. `::String`
|
23
|
+
klass.attached_object.include(Types)
|
18
24
|
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
25
|
end
|
24
|
-
end
|
25
26
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
27
|
+
# Defines an argument using the ServiceBase DSL.
|
28
|
+
# Under the hood, this uses dry-struct's attribute DSL.
|
29
|
+
def argument(name, type, configuration = {}, &)
|
30
|
+
description = configuration[:description] == "" ? nil : configuration[:description]
|
31
|
+
type = type.meta(description:)
|
32
|
+
|
33
|
+
default = configuration[:default]
|
34
|
+
validate_frozen_default!(name:, default:)
|
35
|
+
|
36
|
+
optional = configuration.fetch(:optional, false)
|
37
|
+
validate_optional_or_default!(optional:, default:, name:)
|
38
|
+
|
39
|
+
type = set_default(type:, default:)
|
40
|
+
|
41
|
+
if optional
|
42
|
+
# attribute? allows the key to be omitted.
|
43
|
+
# .optional allows the value to be nil.
|
44
|
+
# https://dry-rb.org/gems/dry-types/1.2/optional-values/
|
45
|
+
# https://github.com/dry-rb/dry-struct/blob/master/lib/dry/struct/class_interface.rb#L141-L169
|
46
|
+
attribute?(name, type.optional, &)
|
47
|
+
else
|
48
|
+
# https://github.com/dry-rb/dry-struct/blob/master/lib/dry/struct/class_interface.rb#L30-L104
|
49
|
+
attribute(name, type, &)
|
50
|
+
end
|
49
51
|
end
|
50
|
-
end
|
51
52
|
|
52
|
-
|
53
|
+
private
|
53
54
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
55
|
+
# Raises a warning from dry-types to avoid memory sharing.
|
56
|
+
# https://github.com/dry-rb/dry-types/blob/master/lib/dry/types/builder.rb#L71-L81
|
57
|
+
def validate_frozen_default!(name:, default:)
|
58
|
+
return if default.frozen?
|
58
59
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
60
|
+
raise(
|
61
|
+
ArgumentError,
|
62
|
+
"#{default} provided as a default value for #{name} is mutable. " \
|
63
|
+
"Please `.freeze` your `default:` input.",
|
64
|
+
)
|
65
|
+
end
|
65
66
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
67
|
+
# Do not allow setting both a default value and optional: true. If both
|
68
|
+
# are specified, the default will not be used.
|
69
|
+
def validate_optional_or_default!(optional:, default:, name:)
|
70
|
+
return unless optional && !default.nil?
|
70
71
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
72
|
+
raise(
|
73
|
+
ArgumentError,
|
74
|
+
"#{name} cannot specify both a default value and optional: true. " \
|
75
|
+
"Only specify a default value if the value is optional.",
|
76
|
+
)
|
77
|
+
end
|
77
78
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
79
|
+
# Ensures that provided args are declared as `argument`s
|
80
|
+
def validate_args!(args:)
|
81
|
+
invalid_args = (args.keys - attribute_names)
|
82
|
+
return if invalid_args.empty?
|
82
83
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
84
|
+
raise(
|
85
|
+
ArgumentError,
|
86
|
+
"#{self} provided invalid arguments: #{invalid_args.join(', ')}",
|
87
|
+
)
|
88
|
+
end
|
88
89
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
90
|
+
# Sets the default value on the type.
|
91
|
+
# For primitive types, the default can be set after initialization.
|
92
|
+
# For enums, the default must be set during initialization. Therefore,
|
93
|
+
# we must check the type of the enum and then reconstruct the enum with
|
94
|
+
# the default value being set.
|
95
|
+
# See "Note" in https://dry-rb.org/gems/dry-types/1.2/enum/
|
96
|
+
def set_default(type:, default:)
|
97
|
+
return type if default.nil?
|
98
|
+
|
99
|
+
if type.is_a?(Dry::Types::Enum)
|
100
|
+
values = type.values
|
101
|
+
type_class = "Types::#{values.first.class}".constantize
|
102
|
+
type_class.default(default).enum(*values)
|
103
|
+
else
|
104
|
+
type.default(default)
|
105
|
+
end
|
104
106
|
end
|
105
107
|
end
|
106
108
|
end
|
data/lib/service_base/service.rb
CHANGED
@@ -7,110 +7,112 @@ require 'dry/monads'
|
|
7
7
|
require 'dry/monads/do'
|
8
8
|
require 'memery'
|
9
9
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
10
|
+
module ServiceBase
|
11
|
+
class Service < Dry::Struct
|
12
|
+
extend Dry::Monads::Result::Mixin::Constructors
|
13
|
+
include Dry::Monads::Do.for(:call)
|
14
|
+
include Dry::Monads[:result, :do]
|
14
15
|
|
15
|
-
|
16
|
-
|
16
|
+
extend ArgumentTypeAnnotations
|
17
|
+
include Memery
|
17
18
|
|
18
|
-
|
19
|
-
|
19
|
+
class ServiceNotSuccessful < StandardError
|
20
|
+
attr_reader(:failure)
|
20
21
|
|
21
|
-
|
22
|
-
|
23
|
-
|
22
|
+
def initialize(failure)
|
23
|
+
super('Failed to call service')
|
24
|
+
@failure = failure
|
25
|
+
end
|
24
26
|
end
|
25
|
-
end
|
26
27
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
28
|
+
class << self
|
29
|
+
# The public class call method.
|
30
|
+
#
|
31
|
+
# The default empty hash is important to prevent an argument error when
|
32
|
+
# passing no arguments to a service that defines defaults for every argument.
|
33
|
+
def call(args = {}, &block)
|
34
|
+
validate_args!(args: args)
|
34
35
|
|
35
|
-
|
36
|
-
|
37
|
-
|
36
|
+
result = new(args).call
|
37
|
+
match_result(result, &block)
|
38
|
+
end
|
38
39
|
|
39
|
-
|
40
|
-
|
41
|
-
|
40
|
+
def call!(*args)
|
41
|
+
result = call(*args)
|
42
|
+
raise(ServiceNotSuccessful, result.failure) if result.failure?
|
42
43
|
|
43
|
-
|
44
|
-
|
44
|
+
result
|
45
|
+
end
|
45
46
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
47
|
+
# Pretty prints (pp) the description of the service, ie. `MyService.pp`
|
48
|
+
def pp
|
49
|
+
logger = Logger.new($stdout)
|
50
|
+
logger.info("#{name}: #{service_description}")
|
51
|
+
logger.info('Arguments')
|
51
52
|
|
52
|
-
|
53
|
-
|
53
|
+
schema_definition.each do |arg|
|
54
|
+
logger.info(" #{arg[:name]} (#{arg[:type]}): #{arg[:description]}")
|
55
|
+
end
|
54
56
|
end
|
55
|
-
end
|
56
57
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
58
|
+
# @description getter
|
59
|
+
def service_description
|
60
|
+
@description || 'No description'
|
61
|
+
end
|
61
62
|
|
62
|
-
|
63
|
+
private
|
63
64
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
65
|
+
# Set the description on the service
|
66
|
+
def description(text)
|
67
|
+
@description = text
|
68
|
+
end
|
68
69
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
70
|
+
# Employs ResultMatcher to unwrap values using `on.success` & `on.failure`
|
71
|
+
# syntax. If not using block form to extract the result of a service,
|
72
|
+
# ie. `MyService.call.fmap { |result| result + 2 }`, ensure you explictly
|
73
|
+
# handle Failures. See https://dry-rb.org/gems/dry-monads/1.3/result/
|
74
|
+
def match_result(result, &block)
|
75
|
+
# https://medium.com/swlh/better-rails-service-objects-with-dry-rb-702687394e3d
|
76
|
+
if block
|
77
|
+
# raises Dry::Matcher::NonExhaustiveMatchError: cases +failure+ not handled
|
78
|
+
# if `on.failure` is not declared
|
79
|
+
Dry::Matcher::ResultMatcher.call(result, &block)
|
80
|
+
else
|
81
|
+
result
|
82
|
+
end
|
81
83
|
end
|
82
|
-
end
|
83
84
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
85
|
+
# Introspects the arguments DSL to extract information on each argument
|
86
|
+
def schema_definition
|
87
|
+
attribute_names.each_with_object([]) do |attribute_name, list|
|
88
|
+
dry_type = schema.key(attribute_name)
|
89
|
+
list << {
|
90
|
+
name: attribute_name,
|
91
|
+
description: dry_type.meta[:description],
|
92
|
+
type: dry_type.type.name
|
93
|
+
}
|
94
|
+
end
|
93
95
|
end
|
94
96
|
end
|
95
|
-
end
|
96
97
|
|
97
|
-
|
98
|
+
private
|
98
99
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
100
|
+
# The call method that must be defined by every inheriting service class
|
101
|
+
def call
|
102
|
+
raise(NotImplementedError)
|
103
|
+
end
|
103
104
|
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
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
|
109
110
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
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
|
114
116
|
end
|
115
117
|
end
|
116
118
|
end
|
data/lib/service_base/types.rb
CHANGED
@@ -10,9 +10,11 @@
|
|
10
10
|
|
11
11
|
require 'dry-types'
|
12
12
|
|
13
|
-
module
|
14
|
-
|
13
|
+
module ServiceBase
|
14
|
+
module Types
|
15
|
+
include Dry.Types()
|
15
16
|
|
16
|
-
|
17
|
-
|
17
|
+
UpCasedString = Types::String.constructor(&:upcase)
|
18
|
+
Boolean = Bool # alias the built in type, Bool
|
19
|
+
end
|
18
20
|
end
|
data/lib/service_base/version.rb
CHANGED
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.
|
4
|
+
version: 1.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- James Klein
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-04-
|
10
|
+
date: 2025-04-02 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: dry-matcher
|