simple-service 0.1.3

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