pakyow-support 0.11.3 → 1.0.0.rc1

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.
Files changed (51) hide show
  1. checksums.yaml +5 -5
  2. data/{pakyow-support/CHANGELOG.md → CHANGELOG.md} +4 -0
  3. data/LICENSE +4 -0
  4. data/{pakyow-support/README.md → README.md} +1 -2
  5. data/lib/pakyow/support/aargv.rb +25 -0
  6. data/lib/pakyow/support/bindable.rb +19 -0
  7. data/lib/pakyow/support/class_state.rb +49 -0
  8. data/lib/pakyow/support/cli/runner.rb +106 -0
  9. data/lib/pakyow/support/cli/style.rb +13 -0
  10. data/lib/pakyow/support/configurable/config.rb +153 -0
  11. data/lib/pakyow/support/configurable/setting.rb +52 -0
  12. data/lib/pakyow/support/configurable.rb +103 -0
  13. data/lib/pakyow/support/core_refinements/array/ensurable.rb +25 -0
  14. data/lib/pakyow/support/core_refinements/method/introspection.rb +21 -0
  15. data/lib/pakyow/support/core_refinements/proc/introspection.rb +21 -0
  16. data/lib/pakyow/support/core_refinements/string/normalization.rb +50 -0
  17. data/lib/pakyow/support/deep_dup.rb +61 -0
  18. data/lib/pakyow/support/deep_freeze.rb +82 -0
  19. data/lib/pakyow/support/definable.rb +242 -0
  20. data/lib/pakyow/support/dependencies.rb +61 -0
  21. data/lib/pakyow/support/extension.rb +82 -0
  22. data/lib/pakyow/support/hookable.rb +227 -0
  23. data/lib/pakyow/support/indifferentize.rb +183 -0
  24. data/lib/pakyow/support/inflector.rb +13 -0
  25. data/lib/pakyow/support/inspectable.rb +88 -0
  26. data/lib/pakyow/support/logging.rb +31 -0
  27. data/lib/pakyow/support/makeable/object_maker.rb +30 -0
  28. data/lib/pakyow/support/makeable/object_name.rb +45 -0
  29. data/lib/pakyow/support/makeable/object_namespace.rb +28 -0
  30. data/lib/pakyow/support/makeable.rb +117 -0
  31. data/lib/pakyow/support/message_verifier.rb +74 -0
  32. data/lib/pakyow/support/path_version.rb +21 -0
  33. data/lib/pakyow/support/pipeline/object.rb +41 -0
  34. data/lib/pakyow/support/pipeline.rb +335 -0
  35. data/lib/pakyow/support/safe_string.rb +60 -0
  36. data/lib/pakyow/support/serializer.rb +49 -0
  37. data/lib/pakyow/support/silenceable.rb +21 -0
  38. data/lib/pakyow/support/string_builder.rb +62 -0
  39. data/lib/pakyow/support.rb +1 -0
  40. metadata +107 -26
  41. data/pakyow-support/LICENSE +0 -20
  42. data/pakyow-support/lib/pakyow/support/aargv.rb +0 -15
  43. data/pakyow-support/lib/pakyow/support/array.rb +0 -9
  44. data/pakyow-support/lib/pakyow/support/dir.rb +0 -32
  45. data/pakyow-support/lib/pakyow/support/dup.rb +0 -23
  46. data/pakyow-support/lib/pakyow/support/file.rb +0 -5
  47. data/pakyow-support/lib/pakyow/support/hash.rb +0 -47
  48. data/pakyow-support/lib/pakyow/support/kernel.rb +0 -9
  49. data/pakyow-support/lib/pakyow/support/string.rb +0 -36
  50. data/pakyow-support/lib/pakyow/support.rb +0 -8
  51. data/pakyow-support/lib/pakyow-support.rb +0 -1
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/hookable"
4
+
5
+ require "pakyow/support/makeable/object_maker"
6
+ require "pakyow/support/makeable/object_name"
7
+
8
+ module Pakyow
9
+ module Support
10
+ # @api private
11
+ module Makeable
12
+ def self.extended(base)
13
+ # Mixin the `make` event for objects that are hookable.
14
+ #
15
+ if base.ancestors.include?(Hookable)
16
+ base.events :make
17
+ end
18
+ end
19
+
20
+ attr_reader :__object_name
21
+
22
+ def make(object_name, within: nil, set_const: true, **kwargs, &block)
23
+ object_name = build_object_name(object_name, within: within)
24
+ object = find_or_define_object(object_name, kwargs, set_const)
25
+
26
+ local_eval_method = eval_method
27
+ object.send(eval_method) do
28
+ @__object_name = object_name
29
+ send(local_eval_method, &block) if block_given?
30
+ end
31
+
32
+ if object.ancestors.include?(Hookable)
33
+ object.call_hooks(:after, :make)
34
+ end
35
+
36
+ object
37
+ end
38
+
39
+ private
40
+
41
+ def build_object_name(object_name, within:)
42
+ unless object_name.is_a?(ObjectName) || object_name.nil?
43
+ namespace = if within && within.respond_to?(:__object_name)
44
+ within.__object_name.namespace
45
+ elsif within.is_a?(ObjectNamespace)
46
+ within
47
+ else
48
+ ObjectNamespace.new
49
+ end
50
+
51
+ object_name_parts = object_name.to_s.gsub("-", "_").split("/").reject(&:empty?)
52
+ class_name = object_name_parts.pop || :index
53
+
54
+ object_name = Support::ObjectName.new(
55
+ Support::ObjectNamespace.new(
56
+ *(namespace.parts + object_name_parts)
57
+ ), class_name
58
+ )
59
+ end
60
+
61
+ object_name
62
+ end
63
+
64
+ def find_or_define_object(object_name, kwargs, set_const)
65
+ if object_name && ::Object.const_defined?(object_name.constant, false)
66
+ existing_object = ::Object.const_get(object_name.constant)
67
+
68
+ if type_of_self?(existing_object)
69
+ existing_object
70
+ else
71
+ define_object(kwargs)
72
+ end
73
+ else
74
+ define_object(kwargs).tap do |defined_object|
75
+ if set_const
76
+ ObjectMaker.define_const_for_object_with_name(defined_object, object_name)
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def type_of_self?(object)
83
+ object.ancestors.include?(ancestors[1])
84
+ end
85
+
86
+ def define_object(kwargs)
87
+ object = case self
88
+ when Class
89
+ Class.new(self)
90
+ when Module
91
+ Module.new do
92
+ def self.__object_name
93
+ @__object_name
94
+ end
95
+ end
96
+ end
97
+
98
+ object.send(eval_method) do
99
+ kwargs.each do |arg, value|
100
+ instance_variable_set(:"@#{arg}", value)
101
+ end
102
+ end
103
+
104
+ object
105
+ end
106
+
107
+ def eval_method
108
+ case self
109
+ when Class
110
+ :class_exec
111
+ when Module
112
+ :module_exec
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require "securerandom"
6
+
7
+ module Pakyow
8
+ module Support
9
+ # Signs and verifes messages for a key.
10
+ #
11
+ class MessageVerifier
12
+ attr_reader :key
13
+
14
+ JOIN_CHARACTER = "--"
15
+
16
+ # TODO: support configuring the digest
17
+ # TODO: support rotations by calling `rotate` with options
18
+ #
19
+ def initialize(key = self.class.key)
20
+ @key = key
21
+ end
22
+
23
+ # Returns a signed message.
24
+ #
25
+ def sign(message)
26
+ [Base64.urlsafe_encode64(message), self.class.digest(message, key: @key)].join(JOIN_CHARACTER)
27
+ end
28
+
29
+ # Returns the message if the signature is valid for the key, or raises `TamperedMessage`.
30
+ #
31
+ def verify(signed)
32
+ message, digest = signed.to_s.split(JOIN_CHARACTER, 2)
33
+
34
+ begin
35
+ message = Base64.urlsafe_decode64(message.to_s)
36
+ rescue ArgumentError
37
+ end
38
+
39
+ if self.class.valid?(digest, message: message, key: @key)
40
+ message
41
+ else
42
+ raise(TamperedMessage)
43
+ end
44
+ end
45
+
46
+ class << self
47
+ # Generates a random key.
48
+ #
49
+ def key
50
+ SecureRandom.hex(24)
51
+ end
52
+
53
+ # Generates a digest for a message with a key.
54
+ #
55
+ def digest(message, key:)
56
+ Base64.urlsafe_encode64(
57
+ OpenSSL::HMAC.digest(
58
+ OpenSSL::Digest.new("sha256"), message.to_s, key.to_s
59
+ )
60
+ )
61
+ end
62
+
63
+ # Returns true if the digest is valid for the message and key.
64
+ #
65
+ def valid?(digest, message:, key:)
66
+ digest == self.digest(message, key: key)
67
+ end
68
+ end
69
+
70
+ class TamperedMessage < StandardError
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module Pakyow
6
+ module Support
7
+ class PathVersion
8
+ # Builds a version based on content at local paths.
9
+ #
10
+ def self.build(*paths)
11
+ paths.each_with_object(Digest::SHA1.new) { |path, digest|
12
+ Dir.glob(File.join(path, "/**/*")).sort.each do |fullpath|
13
+ if File.file?(fullpath)
14
+ digest.update(Digest::SHA1.file(fullpath).to_s)
15
+ end
16
+ end
17
+ }.to_s
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pakyow
4
+ module Support
5
+ module Pipeline
6
+ # Makes an object passable through a pipeline.
7
+ #
8
+ module Object
9
+ def self.included(base)
10
+ base.prepend Initializer
11
+ end
12
+
13
+ def pipelined
14
+ tap do
15
+ @pipelined = true
16
+ end
17
+ end
18
+
19
+ def pipelined?
20
+ @pipelined == true
21
+ end
22
+
23
+ def halt
24
+ @halted = true
25
+ throw :halt, true
26
+ end
27
+
28
+ def halted?
29
+ @halted == true
30
+ end
31
+
32
+ module Initializer
33
+ def initialize(*args)
34
+ @pipelined, @halted = false
35
+ super
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/class_state"
4
+
5
+ module Pakyow
6
+ module Support
7
+ # Provides pipeline behavior. Pipeline objects can define actions to be called in order on an
8
+ # instance of the pipelined object. Each action can act on the state passed to it. Any action
9
+ # can halt the pipeline, causing the result to be immediately returned without calling other
10
+ # actions. State passed through the pipeline should include {Pipelined::Object}.
11
+ #
12
+ # See {Pakyow::App} and {Pakyow::Controller} for examples of more complex pipelines.
13
+ #
14
+ # @example
15
+ # class App
16
+ # include Pakyow::Support::Pipeline
17
+ #
18
+ # action :foo
19
+ # action :bar
20
+ #
21
+ # def foo(result)
22
+ # result << "foo"
23
+ # end
24
+ #
25
+ # def bar(result)
26
+ # result << "bar"
27
+ # end
28
+ # end
29
+ #
30
+ # class Result
31
+ # include Pakyow::Support::Pipeline::Object
32
+ #
33
+ # attr_reader :results
34
+ #
35
+ # def initialize
36
+ # @results = []
37
+ # end
38
+ #
39
+ # def <<(result)
40
+ # @results << result
41
+ # end
42
+ # end
43
+ #
44
+ # App.new.call(Result.new).results
45
+ # => ["foo", "bar"]
46
+ #
47
+ # = Modules
48
+ #
49
+ # Pipeline behavior can be added to a module and then used in a pipelined object.
50
+ #
51
+ # @example
52
+ # module VerifyRequest
53
+ # extend Pakyow::Support::Pipeline
54
+ #
55
+ # action :verify_request
56
+ #
57
+ # def verify_request
58
+ # ...
59
+ # end
60
+ # end
61
+ #
62
+ # class App
63
+ # include Pakyow::Support::Pipeline
64
+ #
65
+ # use_pipeline VerifyRequest
66
+ #
67
+ # ...
68
+ # end
69
+ #
70
+ module Pipeline
71
+ # @api private
72
+ def self.extended(base)
73
+ base.extend ClassMethods
74
+ base.extend ClassState unless base.ancestors.include?(ClassState)
75
+ base.class_state :__pipelines, default: {}, inheritable: true
76
+ base.class_state :__pipeline, inheritable: true
77
+
78
+ base.instance_variable_set(:@__pipeline, Internal.new)
79
+ end
80
+
81
+ # @api private
82
+ def self.included(base)
83
+ base.extend ClassMethods
84
+ base.extend ClassState unless base.ancestors.include?(ClassState)
85
+ base.prepend Initializer
86
+ base.class_state :__pipelines, default: {}, inheritable: true
87
+ base.class_state :__pipeline, inheritable: true
88
+
89
+ # Define a default pipeline so that actions can be defined immediately without ceremony.
90
+ #
91
+ base.pipeline :default do; end
92
+ base.use_pipeline :default
93
+ end
94
+
95
+ # Calls the pipeline, passing +state+.
96
+ #
97
+ def call(state)
98
+ @__pipeline.call(state)
99
+ end
100
+
101
+ def initialize_copy(_)
102
+ super
103
+
104
+ @__pipeline = @__pipeline.dup
105
+
106
+ # rebind any methods to the new instance
107
+ @__pipeline.instance_variable_get(:@stack).map! { |action|
108
+ if action.is_a?(::Method) && action.receiver.is_a?(self.class)
109
+ action.unbind.bind(self)
110
+ else
111
+ action
112
+ end
113
+ }
114
+ end
115
+
116
+ # @api private
117
+ module Initializer
118
+ def initialize(*)
119
+ @__pipeline = self.class.__pipeline.callable(self)
120
+ super
121
+ end
122
+ end
123
+
124
+ module ClassMethods
125
+ # Defines a pipeline.
126
+ #
127
+ def pipeline(name, &block)
128
+ @__pipelines[name.to_sym] = Internal.new(&block)
129
+ end
130
+
131
+ # Uses a pipeline.
132
+ #
133
+ def use_pipeline(name_or_pipeline)
134
+ pipeline = find_pipeline(name_or_pipeline)
135
+ include name_or_pipeline if name_or_pipeline.is_a?(Pipeline)
136
+ @__pipeline = pipeline
137
+ end
138
+
139
+ # Includes actions into the current pipeline.
140
+ #
141
+ def include_pipeline(name_or_pipeline)
142
+ pipeline = find_pipeline(name_or_pipeline)
143
+ include name_or_pipeline if name_or_pipeline.is_a?(Pipeline)
144
+ @__pipeline.include_actions(pipeline.actions)
145
+ end
146
+
147
+ # Excludes actions from the current pipeline.
148
+ #
149
+ def exclude_pipeline(name_or_pipeline)
150
+ pipeline = find_pipeline(name_or_pipeline)
151
+ @__pipeline.exclude_actions(pipeline.actions)
152
+ end
153
+
154
+ # Defines an action on the current pipeline.
155
+ #
156
+ def action(action = nil, *options, before: nil, after: nil, &block)
157
+ @__pipeline.action(action, *options, before: before, after: after, &block)
158
+ end
159
+
160
+ def skip(*actions)
161
+ @__pipeline.skip(*actions)
162
+ end
163
+
164
+ private
165
+
166
+ def find_pipeline(name_or_pipeline)
167
+ if name_or_pipeline.is_a?(Pipeline)
168
+ name_or_pipeline.instance_variable_get(:@__pipeline)
169
+ elsif name_or_pipeline.is_a?(Internal)
170
+ name_or_pipeline
171
+ else
172
+ name_or_pipeline = name_or_pipeline.to_sym
173
+ if @__pipelines.key?(name_or_pipeline)
174
+ @__pipelines[name_or_pipeline]
175
+ else
176
+ raise ArgumentError, "could not find a pipeline named `#{name_or_pipeline}'"
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ # @api private
183
+ class Internal
184
+ attr_reader :actions
185
+
186
+ def initialize
187
+ @actions = []
188
+
189
+ if block_given?
190
+ instance_exec(&Proc.new)
191
+ end
192
+ end
193
+
194
+ def initialize_copy(_)
195
+ @actions = @actions.dup
196
+ super
197
+ end
198
+
199
+ def callable(context)
200
+ Callable.new(@actions, context)
201
+ end
202
+
203
+ def action(target, *options, before: nil, after: nil, &block)
204
+ Action.new(target, *options, &block).tap do |action|
205
+ if before
206
+ if i = @actions.index { |a| a.name == before }
207
+ @actions.insert(i, action)
208
+ else
209
+ @actions.unshift(action)
210
+ end
211
+ elsif after
212
+ if i = @actions.index { |a| a.name == after }
213
+ @actions.insert(i + 1, action)
214
+ else
215
+ @actions << action
216
+ end
217
+ else
218
+ @actions << action
219
+ end
220
+ end
221
+ end
222
+
223
+ def skip(*actions)
224
+ @actions.delete_if { |action|
225
+ actions.include?(action.name)
226
+ }
227
+ end
228
+
229
+ def include_actions(actions)
230
+ @actions.concat(actions).uniq!
231
+ end
232
+
233
+ def exclude_actions(actions)
234
+ # Map input into a common denominator, to exclude both names and other action objects.
235
+ targets = actions.map { |action|
236
+ if action.is_a?(Action)
237
+ action.target
238
+ else
239
+ action
240
+ end
241
+ }
242
+
243
+ @actions.delete_if { |action|
244
+ targets.include?(action.target)
245
+ }
246
+ end
247
+ end
248
+
249
+ # @api private
250
+ class Callable
251
+ def initialize(actions, context)
252
+ @stack = actions.map { |action|
253
+ action.finalize(context)
254
+ }
255
+ end
256
+
257
+ def initialize_copy(_)
258
+ @stack = @stack.dup
259
+
260
+ super
261
+ end
262
+
263
+ def call(object, stack = @stack.dup)
264
+ catch :halt do
265
+ until stack.empty? || (object.respond_to?(:halted?) && object.halted?)
266
+ action = stack.shift
267
+ if action.arity == 0
268
+ action.call do
269
+ call(object, stack)
270
+ end
271
+ else
272
+ action.call(object) do
273
+ call(object, stack)
274
+ end
275
+ end
276
+ end
277
+ end
278
+
279
+ object.pipelined
280
+ end
281
+ end
282
+
283
+ # @api private
284
+ class Action
285
+ attr_reader :target, :name, :options
286
+
287
+ def initialize(target, *options, &block)
288
+ @target, @options, @block = target, options, block
289
+
290
+ if target.is_a?(Symbol)
291
+ @name = target
292
+ end
293
+ end
294
+
295
+ def finalize(context = nil)
296
+ if @block
297
+ if context
298
+ if @block.arity == 0
299
+ Proc.new do
300
+ context.instance_exec(&@block)
301
+ end
302
+ else
303
+ Proc.new do |object|
304
+ context.instance_exec(object, &@block)
305
+ end
306
+ end
307
+ else
308
+ @block
309
+ end
310
+ elsif @target.is_a?(Symbol) && context.respond_to?(@target, true)
311
+ if context
312
+ context.method(@target)
313
+ else
314
+ raise "finalizing pipeline action #{@target} requires context"
315
+ end
316
+ else
317
+ target, target_options = if @target.is_a?(Symbol)
318
+ [@options[0], @options[1..-1]]
319
+ else
320
+ [@target, @options]
321
+ end
322
+
323
+ instance = if target.is_a?(Class)
324
+ target.new(*target_options)
325
+ else
326
+ target
327
+ end
328
+
329
+ instance.method(:call)
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Pakyow
6
+ module Support
7
+ class SafeString < String
8
+ def initialize(*)
9
+ super; freeze
10
+ end
11
+
12
+ def to_s
13
+ self
14
+ end
15
+ end
16
+
17
+ # Helper methods for ensuring string safety.
18
+ #
19
+ module SafeStringHelpers
20
+ extend self
21
+
22
+ # Escapes the string unless it's marked as safe.
23
+ #
24
+ def ensure_html_safety(string)
25
+ html_safe?(string) ? string : html_escape(string)
26
+ end
27
+
28
+ # Returns true if the string is marked as safe.
29
+ #
30
+ def html_safe?(string)
31
+ string.is_a?(SafeString)
32
+ end
33
+
34
+ # Marks a string as safe.
35
+ #
36
+ def html_safe(string)
37
+ html_safe?(string) ? string : SafeString.new(string)
38
+ end
39
+
40
+ # Escapes html characters in the string.
41
+ #
42
+ def html_escape(string)
43
+ html_safe(CGI.escape_html(string.to_s))
44
+ end
45
+
46
+ # Strips html tags from the string.
47
+ #
48
+ def strip_tags(string)
49
+ html_safe(string.to_s.gsub(/<[^>]*>/ui, ""))
50
+ end
51
+
52
+ # Strips html tags from the string, except for tags specified.
53
+ #
54
+ def sanitize(string, tags: [])
55
+ return strip_tags(string) if tags.empty?
56
+ html_safe(string.to_s.gsub(/((?!<((\/)?#{tags.join("|")}))<[^>]*>)/i, ""))
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require "pakyow/support/logging"
6
+
7
+ module Pakyow
8
+ module Support
9
+ # Persists state for an object.
10
+ #
11
+ class Serializer
12
+ attr_reader :object, :name, :path
13
+
14
+ def initialize(object, name:, path:)
15
+ @object, @name, @path = object, name, path
16
+ end
17
+
18
+ def serialize
19
+ FileUtils.mkdir_p(@path)
20
+ File.open(serialized_state_path, "w+") do |file|
21
+ file.write(Marshal.dump(@object.serialize))
22
+ end
23
+ rescue => error
24
+ Logging.yield_or_raise(error) do |logger|
25
+ logger.error "[Serializer] failed to serialize `#{@name}': #{error}"
26
+ end
27
+ end
28
+
29
+ def deserialize
30
+ if File.exist?(serialized_state_path)
31
+ Marshal.load(File.read(serialized_state_path)).each do |ivar, value|
32
+ @object.instance_variable_set(ivar, value)
33
+ end
34
+ end
35
+ rescue => error
36
+ FileUtils.rm(serialized_state_path)
37
+ Logging.yield_or_raise(error) do |logger|
38
+ logger.error "[Serializer] failed to deserialize `#{@name}': #{error}"
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def serialized_state_path
45
+ File.join(@path, "#{@name}.pwstate")
46
+ end
47
+ end
48
+ end
49
+ end