simple-service 0.1.3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|