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.
@@ -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