simple-service 0.1.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 +7 -0
- data/.gitignore +12 -0
- data/.rubocop.yml +100 -0
- data/.tm_properties +1 -0
- data/Gemfile +14 -0
- data/Makefile +16 -0
- data/README.md +3 -0
- data/Rakefile +6 -0
- data/VERSION +1 -0
- data/bin/bundle +105 -0
- data/bin/console +15 -0
- data/bin/rake +29 -0
- data/bin/rspec +29 -0
- data/lib/simple-service.rb +3 -0
- data/lib/simple/service.rb +103 -0
- data/lib/simple/service/action.rb +203 -0
- data/lib/simple/service/action/comment.rb +57 -0
- data/lib/simple/service/action/indie_hash.rb +37 -0
- data/lib/simple/service/action/method_reflection.rb +70 -0
- data/lib/simple/service/action/parameter.rb +42 -0
- data/lib/simple/service/context.rb +94 -0
- data/lib/simple/service/errors.rb +54 -0
- data/lib/simple/service/version.rb +29 -0
- data/log/.gitkeep +0 -0
- data/scripts/release +2 -0
- data/scripts/release.rb +91 -0
- data/scripts/stats +5 -0
- data/scripts/watch +2 -0
- data/simple-service.gemspec +25 -0
- data/spec/simple/service/action_invoke2_spec.rb +166 -0
- data/spec/simple/service/action_invoke_spec.rb +266 -0
- data/spec/simple/service/action_spec.rb +51 -0
- data/spec/simple/service/context_spec.rb +69 -0
- data/spec/simple/service/service_spec.rb +105 -0
- data/spec/simple/service/version_spec.rb +7 -0
- data/spec/spec_helper.rb +38 -0
- data/spec/support/spec_services.rb +50 -0
- metadata +99 -0
@@ -0,0 +1,203 @@
|
|
1
|
+
module Simple::Service
|
2
|
+
class Action
|
3
|
+
end
|
4
|
+
end
|
5
|
+
|
6
|
+
require_relative "./action/comment"
|
7
|
+
require_relative "./action/parameter"
|
8
|
+
require_relative "./action/indie_hash"
|
9
|
+
|
10
|
+
module Simple::Service
|
11
|
+
# rubocop:disable Metrics/AbcSize
|
12
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
13
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
14
|
+
# rubocop:disable Style/GuardClause
|
15
|
+
# rubocop:disable Metrics/ClassLength
|
16
|
+
|
17
|
+
class Action
|
18
|
+
IDENTIFIER_PATTERN = "[a-z][a-z0-9_]*" # :nodoc:
|
19
|
+
IDENTIFIER_REGEXP = Regexp.compile("\\A#{IDENTIFIER_PATTERN}\\z") # :nodoc:
|
20
|
+
|
21
|
+
# determines all services provided by the +service+ service module.
|
22
|
+
def self.enumerate(service:) # :nodoc:
|
23
|
+
service.public_instance_methods(false)
|
24
|
+
.grep(IDENTIFIER_REGEXP)
|
25
|
+
.each_with_object({}) { |name, hsh| hsh[name] = Action.new(service, name) }
|
26
|
+
end
|
27
|
+
|
28
|
+
attr_reader :service
|
29
|
+
attr_reader :name
|
30
|
+
|
31
|
+
def full_name
|
32
|
+
"#{service.name}##{name}"
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_s # :nodoc:
|
36
|
+
full_name
|
37
|
+
end
|
38
|
+
|
39
|
+
# returns an Array of Parameter structures.
|
40
|
+
def parameters
|
41
|
+
@parameters ||= Parameter.reflect_on_method(service: service, name: name)
|
42
|
+
end
|
43
|
+
|
44
|
+
def initialize(service, name) # :nodoc:
|
45
|
+
@service = service
|
46
|
+
@name = name
|
47
|
+
|
48
|
+
parameters
|
49
|
+
end
|
50
|
+
|
51
|
+
def short_description
|
52
|
+
comment.short
|
53
|
+
end
|
54
|
+
|
55
|
+
def full_description
|
56
|
+
comment.full
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
# returns a Comment object
|
62
|
+
#
|
63
|
+
# The comment object is extracted on demand on the first call.
|
64
|
+
def comment
|
65
|
+
@comment ||= Comment.extract(action: self)
|
66
|
+
end
|
67
|
+
|
68
|
+
public
|
69
|
+
|
70
|
+
def source_location
|
71
|
+
@service.instance_method(name).source_location
|
72
|
+
end
|
73
|
+
|
74
|
+
# build a service_instance and run the action, with arguments constructed from
|
75
|
+
# args_hsh and params_hsh.
|
76
|
+
def invoke(*args, **named_args)
|
77
|
+
# convert Array arguments into a Hash of named arguments. This is strictly
|
78
|
+
# necessary to be able to apply default value-based type conversions. (On
|
79
|
+
# the downside this also means we convert an array to a hash and then back
|
80
|
+
# into an array. This, however, should only be an issue for CLI based action
|
81
|
+
# invocations, because any other use case (that I can think of) should allow
|
82
|
+
# us to provide arguments as a Hash.
|
83
|
+
args = convert_argument_array_to_hash(args)
|
84
|
+
named_args = named_args.merge(args)
|
85
|
+
|
86
|
+
invoke2(args: named_args, flags: {})
|
87
|
+
end
|
88
|
+
|
89
|
+
# invokes an action with a given +name+ in a service with a Hash of arguments.
|
90
|
+
#
|
91
|
+
# You cannot call this method if the context is not set.
|
92
|
+
def invoke2(args:, flags:)
|
93
|
+
# args and flags are being stringified. This is necessary to not allow any
|
94
|
+
# unchecked input to DOS this process by just providing always changing
|
95
|
+
# key values.
|
96
|
+
args = IndieHash.new(args)
|
97
|
+
flags = IndieHash.new(flags)
|
98
|
+
|
99
|
+
verify_required_args!(args, flags)
|
100
|
+
|
101
|
+
positionals = build_positional_arguments(args, flags)
|
102
|
+
keywords = build_keyword_arguments(args.merge(flags))
|
103
|
+
|
104
|
+
service_instance = Object.new
|
105
|
+
service_instance.extend service
|
106
|
+
|
107
|
+
if keywords.empty?
|
108
|
+
service_instance.public_send(@name, *positionals)
|
109
|
+
else
|
110
|
+
# calling this with an empty keywords Hash still raises an ArgumentError
|
111
|
+
# if the target method does not accept arguments.
|
112
|
+
service_instance.public_send(@name, *positionals, **keywords)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
# returns an error if the keywords hash does not define all required keyword arguments.
|
119
|
+
def verify_required_args!(args, flags) # :nodoc:
|
120
|
+
@required_names ||= parameters.select(&:required?).map(&:name).map(&:to_s)
|
121
|
+
|
122
|
+
missing_parameters = @required_names - args.keys - flags.keys
|
123
|
+
return if missing_parameters.empty?
|
124
|
+
|
125
|
+
raise ::Simple::Service::MissingArguments.new(self, missing_parameters)
|
126
|
+
end
|
127
|
+
|
128
|
+
# Enumerating all parameters it puts all named parameters into a Hash
|
129
|
+
# of keyword arguments.
|
130
|
+
def build_keyword_arguments(args)
|
131
|
+
@keyword_names ||= parameters.select(&:keyword?).map(&:name).map(&:to_s)
|
132
|
+
|
133
|
+
keys = @keyword_names & args.keys
|
134
|
+
values = args.fetch_values(*keys)
|
135
|
+
|
136
|
+
# Note that +keys+ now only contains names of keyword arguments that actually exist.
|
137
|
+
# This is therefore not a way to DOS this process.
|
138
|
+
Hash[keys.map(&:to_sym).zip(values)]
|
139
|
+
end
|
140
|
+
|
141
|
+
def variadic_parameter
|
142
|
+
return @variadic_parameter if defined? @variadic_parameter
|
143
|
+
|
144
|
+
@variadic_parameter = parameters.detect(&:variadic?)
|
145
|
+
end
|
146
|
+
|
147
|
+
def positional_names
|
148
|
+
@positional_names ||= parameters.select(&:positional?).map(&:name)
|
149
|
+
end
|
150
|
+
|
151
|
+
# Enumerating all parameters it collects all positional parameters into
|
152
|
+
# an Array.
|
153
|
+
def build_positional_arguments(args, flags)
|
154
|
+
positionals = positional_names.each_with_object([]) do |parameter_name, ary|
|
155
|
+
if args.key?(parameter_name)
|
156
|
+
ary << args[parameter_name]
|
157
|
+
elsif flags.key?(parameter_name)
|
158
|
+
ary << flags[parameter_name]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# A variadic parameter is appended to the positionals array.
|
163
|
+
# It is always optional - but if it exists it must be an Array.
|
164
|
+
if variadic_parameter
|
165
|
+
value = if args.key?(variadic_parameter.name)
|
166
|
+
args[variadic_parameter.name]
|
167
|
+
elsif flags.key?(variadic_parameter.name)
|
168
|
+
flags[variadic_parameter.name]
|
169
|
+
end
|
170
|
+
|
171
|
+
positionals.concat(value) if value
|
172
|
+
end
|
173
|
+
|
174
|
+
positionals
|
175
|
+
end
|
176
|
+
|
177
|
+
def convert_argument_array_to_hash(ary)
|
178
|
+
expect! ary => Array
|
179
|
+
|
180
|
+
hsh = {}
|
181
|
+
|
182
|
+
if variadic_parameter
|
183
|
+
hsh[variadic_parameter.name] = []
|
184
|
+
end
|
185
|
+
|
186
|
+
if ary.length > positional_names.length
|
187
|
+
extra_arguments = ary[positional_names.length..-1]
|
188
|
+
|
189
|
+
if variadic_parameter
|
190
|
+
hsh[variadic_parameter.name] = extra_arguments
|
191
|
+
else
|
192
|
+
raise ::Simple::Service::ExtraArguments.new(self, extra_arguments)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
ary.zip(positional_names).each do |value, parameter_name|
|
197
|
+
hsh[parameter_name] = value
|
198
|
+
end
|
199
|
+
|
200
|
+
hsh
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# returns the comment for an action
|
2
|
+
class ::Simple::Service::Action::Comment # :nodoc:
|
3
|
+
attr_reader :short
|
4
|
+
attr_reader :full
|
5
|
+
|
6
|
+
def self.extract(action:)
|
7
|
+
file, line = action.source_location
|
8
|
+
lines = Extractor.extract_comment_lines(file: file, before_line: line)
|
9
|
+
full = lines[2..-1].join("\n") if lines.length >= 2
|
10
|
+
new short: lines[0], full: full
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(short:, full:)
|
14
|
+
@short, @full = short, full
|
15
|
+
end
|
16
|
+
|
17
|
+
module Extractor
|
18
|
+
extend self
|
19
|
+
|
20
|
+
# reads the source \a file and turns each non-comment into :code and each comment
|
21
|
+
# into a string without the leading comment markup.
|
22
|
+
def parse_source(file)
|
23
|
+
@parsed_sources ||= {}
|
24
|
+
@parsed_sources[file] = _parse_source(file)
|
25
|
+
end
|
26
|
+
|
27
|
+
def _parse_source(file)
|
28
|
+
File.readlines(file).map do |line|
|
29
|
+
case line
|
30
|
+
when /^\s*# ?(.*)$/ then $1
|
31
|
+
when /^\s*end/ then :end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def extract_comment_lines(file:, before_line:)
|
37
|
+
parsed_source = parse_source(file)
|
38
|
+
|
39
|
+
# go down from before_line until we see a line which is either a comment
|
40
|
+
# or an :end. Note that the line at before_line-1 should be the first
|
41
|
+
# line of the method definition in question.
|
42
|
+
last_line = before_line - 1
|
43
|
+
last_line -= 1 while last_line >= 0 && !parsed_source[last_line]
|
44
|
+
|
45
|
+
first_line = last_line
|
46
|
+
first_line -= 1 while first_line >= 0 && parsed_source[first_line]
|
47
|
+
first_line += 1
|
48
|
+
|
49
|
+
comments = parsed_source[first_line..last_line]
|
50
|
+
if comments.include?(:end)
|
51
|
+
[]
|
52
|
+
else
|
53
|
+
parsed_source[first_line..last_line]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class Simple::Service::Action
|
2
|
+
# The IndieHash class defines as much of the Hash interface as necessary for simple-service
|
3
|
+
# to successfully run.
|
4
|
+
class IndieHash
|
5
|
+
def initialize(hsh)
|
6
|
+
@hsh = hsh.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
7
|
+
end
|
8
|
+
|
9
|
+
def keys
|
10
|
+
@hsh.keys
|
11
|
+
end
|
12
|
+
|
13
|
+
def fetch_values(*keys)
|
14
|
+
keys = keys.map(&:to_s)
|
15
|
+
@hsh.fetch_values(*keys)
|
16
|
+
end
|
17
|
+
|
18
|
+
def key?(sym)
|
19
|
+
@hsh.key?(sym.to_s)
|
20
|
+
end
|
21
|
+
|
22
|
+
def [](sym)
|
23
|
+
@hsh[sym.to_s]
|
24
|
+
end
|
25
|
+
|
26
|
+
def merge(other_hsh)
|
27
|
+
@hsh = @hsh.merge(other_hsh.send(:__hsh__))
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def __hsh__
|
34
|
+
@hsh
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# rubocop:disable Metrics/AbcSize
|
2
|
+
|
3
|
+
module ::Simple::Service::Action::MethodReflection # :nodoc:
|
4
|
+
extend self
|
5
|
+
|
6
|
+
#
|
7
|
+
# returns an array with entries like the following:
|
8
|
+
#
|
9
|
+
# [ :key, name, default_value ]
|
10
|
+
# [ :keyreq, name [, nil ] ]
|
11
|
+
# [ :req, name [, nil ] ]
|
12
|
+
# [ :opt, name [, nil ] ]
|
13
|
+
# [ :rest, name [, nil ] ]
|
14
|
+
#
|
15
|
+
def parameters(service, method_id)
|
16
|
+
method = service.instance_method(method_id)
|
17
|
+
parameters = method.parameters
|
18
|
+
|
19
|
+
# method parameters with a :key mode are optional named arguments. We only
|
20
|
+
# support defaults for those - if there are none we abort here already.
|
21
|
+
keys = parameters.map { |mode, name| name if mode == :key }.compact
|
22
|
+
return parameters if keys.empty?
|
23
|
+
|
24
|
+
# We are now doing a fake call to the method, with a minimal viable set of
|
25
|
+
# arguments, to let the ruby runtime fill in default values for arguments.
|
26
|
+
# We do not, however, let the call complete. Instead we use a TracePoint to
|
27
|
+
# abort as soon as the method is called, and use the its binding to determine
|
28
|
+
# the default values.
|
29
|
+
|
30
|
+
fake_recipient = Object.new.extend(service)
|
31
|
+
fake_call_args = minimal_arguments(method)
|
32
|
+
|
33
|
+
trace_point = TracePoint.trace(:call) do |tp|
|
34
|
+
throw :received_fake_call, tp.binding if tp.defined_class == service && tp.method_id == method_id
|
35
|
+
end
|
36
|
+
|
37
|
+
bnd = catch(:received_fake_call) do
|
38
|
+
fake_recipient.send(method_id, *fake_call_args)
|
39
|
+
end
|
40
|
+
|
41
|
+
trace_point.disable
|
42
|
+
|
43
|
+
# extract default values from the received binding, and merge with the
|
44
|
+
# parameters array.
|
45
|
+
default_values = keys.each_with_object({}) do |key_parameter, hsh|
|
46
|
+
hsh[key_parameter] = bnd.local_variable_get(key_parameter)
|
47
|
+
end
|
48
|
+
|
49
|
+
parameters.map do |mode, name|
|
50
|
+
[mode, name, default_values[name]]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# returns a minimal Array of arguments, which is suitable for a call to the method
|
57
|
+
def minimal_arguments(method)
|
58
|
+
# Build an arguments array with holds all required parameters. The actual
|
59
|
+
# values for these arguments doesn't matter at all.
|
60
|
+
args = method.parameters.select { |mode, _name| mode == :req }
|
61
|
+
|
62
|
+
# Add a hash with all required named arguments
|
63
|
+
required_keyword_args = method.parameters.each_with_object({}) do |(mode, name), hsh|
|
64
|
+
hsh[name] = :anything if mode == :keyreq
|
65
|
+
end
|
66
|
+
args << required_keyword_args if required_keyword_args
|
67
|
+
|
68
|
+
args
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require_relative "method_reflection"
|
2
|
+
|
3
|
+
class ::Simple::Service::Action::Parameter
|
4
|
+
def self.reflect_on_method(service:, name:)
|
5
|
+
reflected_parameters = ::Simple::Service::Action::MethodReflection.parameters(service, name)
|
6
|
+
@parameters = reflected_parameters.map { |ary| new(*ary) }
|
7
|
+
end
|
8
|
+
|
9
|
+
def keyword?
|
10
|
+
[:keyreq, :key].include? @kind
|
11
|
+
end
|
12
|
+
|
13
|
+
def positional?
|
14
|
+
[:req, :opt].include? @kind
|
15
|
+
end
|
16
|
+
|
17
|
+
def required?
|
18
|
+
[:req, :keyreq].include? @kind
|
19
|
+
end
|
20
|
+
|
21
|
+
def variadic?
|
22
|
+
@kind == :rest
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_reader :name
|
26
|
+
attr_reader :kind
|
27
|
+
|
28
|
+
# The parameter's default value (if any)
|
29
|
+
attr_reader :default_value
|
30
|
+
|
31
|
+
def initialize(kind, name, *default_value)
|
32
|
+
# The parameter list matches the values returned from MethodReflection.parameters,
|
33
|
+
# which has two or three entries: <tt>kind, name [ . default_value ]</tt>
|
34
|
+
|
35
|
+
expect! kind => [:req, :opt, :keyreq, :key, :rest]
|
36
|
+
expect! default_value.length => [0, 1]
|
37
|
+
|
38
|
+
@kind = kind
|
39
|
+
@name = name
|
40
|
+
@default_value = default_value[0]
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
module Simple::Service
|
2
|
+
# Returns the current context.
|
3
|
+
def self.context
|
4
|
+
Thread.current[:"Simple::Service.context"]
|
5
|
+
end
|
6
|
+
|
7
|
+
# yields a block with a given context, and restores the previous context
|
8
|
+
# object afterwards.
|
9
|
+
def self.with_context(ctx = nil, &block)
|
10
|
+
old_ctx = Thread.current[:"Simple::Service.context"]
|
11
|
+
new_ctx = old_ctx ? old_ctx.merge(ctx) : Context.new(ctx)
|
12
|
+
|
13
|
+
Thread.current[:"Simple::Service.context"] = new_ctx
|
14
|
+
|
15
|
+
block.call
|
16
|
+
ensure
|
17
|
+
Thread.current[:"Simple::Service.context"] = old_ctx
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
module Simple::Service
|
22
|
+
# A context object
|
23
|
+
#
|
24
|
+
# Each service executes with a current context. The system manages a stack of
|
25
|
+
# contexts; whenever a service execution is done the current context is reverted
|
26
|
+
# to its previous value.
|
27
|
+
#
|
28
|
+
# A context object can store a large number of values; the only way to set or
|
29
|
+
# access a value is via getters and setters. These are implemented via
|
30
|
+
# +method_missing(..)+.
|
31
|
+
#
|
32
|
+
# Also, once a value is set in the context it is not possible to change or
|
33
|
+
# unset it.
|
34
|
+
class Context
|
35
|
+
def initialize(hsh = {}) # :nodoc:
|
36
|
+
@hsh = hsh
|
37
|
+
end
|
38
|
+
|
39
|
+
# returns a new Context object, which merges the values in the +overlay+
|
40
|
+
# argument (which must be a Hash or nil) with the values in this context.
|
41
|
+
#
|
42
|
+
# The overlay is allowed to change values in the current context.
|
43
|
+
#
|
44
|
+
# It does not change this context.
|
45
|
+
def merge(overlay)
|
46
|
+
expect! overlay => [Hash, nil]
|
47
|
+
|
48
|
+
overlay ||= {}
|
49
|
+
new_context_hsh = @hsh.merge(overlay)
|
50
|
+
::Simple::Service::Context.new(new_context_hsh)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
IDENTIFIER_PATTERN = "[a-z][a-z0-9_]*" # :nodoc:
|
56
|
+
IDENTIFIER_REGEXP = Regexp.compile("\\A#{IDENTIFIER_PATTERN}\\z") # :nodoc:
|
57
|
+
ASSIGNMENT_REGEXP = Regexp.compile("\\A(#{IDENTIFIER_PATTERN})=\\z") # :nodoc:
|
58
|
+
|
59
|
+
def method_missing(sym, *args, &block)
|
60
|
+
raise ArgumentError, "Block given" if block
|
61
|
+
|
62
|
+
if args.count == 0 && sym =~ IDENTIFIER_REGEXP
|
63
|
+
self[sym]
|
64
|
+
elsif args.count == 1 && sym =~ ASSIGNMENT_REGEXP
|
65
|
+
self[$1.to_sym] = args.first
|
66
|
+
else
|
67
|
+
super
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def respond_to_missing?(sym, include_private = false)
|
72
|
+
# :nocov:
|
73
|
+
return true if IDENTIFIER_REGEXP.match?(sym)
|
74
|
+
return true if ASSIGNMENT_REGEXP.match?(sym)
|
75
|
+
|
76
|
+
super
|
77
|
+
# :nocov:
|
78
|
+
end
|
79
|
+
|
80
|
+
def [](key)
|
81
|
+
@hsh[key]
|
82
|
+
end
|
83
|
+
|
84
|
+
def []=(key, value)
|
85
|
+
existing_value = @hsh[key]
|
86
|
+
|
87
|
+
unless existing_value.nil? || existing_value == value
|
88
|
+
raise ::Simple::Service::ContextReadOnlyError, key
|
89
|
+
end
|
90
|
+
|
91
|
+
@hsh[key] = value
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|