mimi-core 0.2.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,538 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'json'
5
+ require 'yaml'
6
+
7
+ module Mimi
8
+ module Core
9
+ #
10
+ # Manifest represents a set of definitions of configurable parameters.
11
+ #
12
+ # It is a way of formally declaring which configurable parameters are accepted by a Mimi module,
13
+ # application etc. A Manifest object is also used to validate passed set of raw values,
14
+ # apply rules and produce a set of parsed configurable parameter values.
15
+ #
16
+ # Manifests are constructed from a Hash representation, following some structure.
17
+ # Configurable parameter definitions are specified in the manifest Hash as
18
+ # key-value pairs, where *key* is the name of the configurable parameter, and
19
+ # *value* is a Hash with parameter properties.
20
+ #
21
+ # Example:
22
+ #
23
+ # ```ruby
24
+ # manifest = Mimi::Core::Manifest.new(
25
+ # var1: {}, # minimalistic configurable parameter definition, all properties are default
26
+ # var2: {}
27
+ # )
28
+ # ```
29
+ #
30
+ # The properties that can be defined for a configurable parameter are:
31
+ #
32
+ # * `:desc` (String) -- a human readable description of the parameter (default: nil)
33
+ # * `:type` (Symbol,Array<String>) -- defines the type of the parameter and the type/format
34
+ # of accepted values (default: :string)
35
+ # * `:default` (Object) -- specified default value indicates that the parameter is optional
36
+ # * `:hidden` (true,false) -- if set to true, omits the parameter from the application's
37
+ # combined manifest
38
+ # * `:const` (true,false) -- if set to true, this configurable parameter cannot be changed
39
+ # and always equals to its default value which must be specified
40
+ #
41
+ # ## Configurable parameter properties
42
+ #
43
+ # ### :desc => <String>
44
+ #
45
+ # Default: `nil`
46
+ #
47
+ # Allows to specify a human readable description for a configurable parameter.
48
+ #
49
+ # Example:
50
+ #
51
+ # ```ruby
52
+ # manifest = Mimi::Core::Manifest.new(
53
+ # var1: {
54
+ # desc: 'My configurable parameter 1'
55
+ # }
56
+ # }
57
+ # ```
58
+ #
59
+ #
60
+ # ### :type => <Symbol,Array<String>>
61
+ #
62
+ # Default: `:string`
63
+ #
64
+ # Defines the type of the parameter and accepted values. Recognised types are:
65
+ #
66
+ # * `:string` -- accepts any value, presents it as a `String`
67
+ # * `:integer` -- accepts any `Integer` value or a valid `String` representation of integer
68
+ # * `:decimal` -- accepts `BigDecimal` value or a valid `String` representation
69
+ # of a decimal number
70
+ # * `:boolean` -- accepts `true` or `false` or string literals `'true'`, `'false'`
71
+ # * `:json` -- accepts a string with valid JSON, presents it as a parsed object
72
+ # (literal, Array or Hash)
73
+ # * `Array<String>` -- defines enumeration of values, e.g. `['debug', 'info', 'warn', 'error']`;
74
+ # only values enumerated in the list are accepted, presented as `String`
75
+ #
76
+ # Example:
77
+ #
78
+ # ```ruby
79
+ # manifest = Mimi::Core::Manifest.new(
80
+ # var1: {
81
+ # type: :integer,
82
+ # default: 1
83
+ # },
84
+ #
85
+ # var2: {
86
+ # type: :decimal,
87
+ # default: '0.01'
88
+ # },
89
+ #
90
+ # var3: {
91
+ # type: ['debug', 'info', 'warn', 'error'],
92
+ # default: 'info'
93
+ # }
94
+ # }
95
+ # ```
96
+ #
97
+ # ### :default => <Object, Proc>
98
+ #
99
+ # Default: `nil`
100
+ #
101
+ # ...
102
+ #
103
+ # ### :hidden => <true,false>
104
+ #
105
+ # Default: `false`
106
+ #
107
+ # ...
108
+ #
109
+ # ### :const => <true,false>
110
+ #
111
+ # Default: `false`
112
+ #
113
+ # ...
114
+ #
115
+ #
116
+ # Example:
117
+ # manifest_hash = {
118
+ # var1: {
119
+ # desc: 'My var 1',
120
+ # type: :string,
121
+ #
122
+ # }
123
+ # }
124
+ #
125
+ class Manifest
126
+ ALLOWED_TYPES = %w[string integer decimal boolean json].freeze
127
+
128
+ # Constructs a new Manifest from its Hash representation
129
+ #
130
+ # @param manifest_hash [Hash,nil] default is empty manifest
131
+ #
132
+ def initialize(manifest_hash = {})
133
+ self.class.validate_manifest_hash(manifest_hash)
134
+ @manifest = manifest_hash_canonical(manifest_hash.deep_dup)
135
+ end
136
+
137
+ # Returns a Hash representation of the Manifest
138
+ #
139
+ # @return [Hash]
140
+ #
141
+ def to_h
142
+ @manifest
143
+ end
144
+
145
+ # Returns a list of configurable parameter names
146
+ #
147
+ # @return [Array<Symbol>]
148
+ #
149
+ def keys
150
+ @manifest.keys
151
+ end
152
+
153
+ # Returns true if the configurable parameter is a required one
154
+ #
155
+ # @param name [Symbol] the name of configurable parameter
156
+ # @return [true,false]
157
+ #
158
+ def required?(name)
159
+ raise ArgumentError, 'Symbol is expected as the parameter name' unless name.is_a?(Symbol)
160
+ props = @manifest[name]
161
+ return false unless props # parameter is not required if it is not declared
162
+ !props.keys.include?(:default)
163
+ end
164
+
165
+ # Merges current Manifest with another Hash or Manifest, modifies current Manifest in-place
166
+ #
167
+ # @param another [Mimi::Core::Manifest,Hash]
168
+ #
169
+ def merge!(another)
170
+ @manifest = merge(another).to_h
171
+ end
172
+
173
+ # Returns a copy of current Manifest merged with another Hash or Manifest
174
+ #
175
+ # @param another [Mimi::Core::Manifest,Hash]
176
+ # @return [Mimi::Core::Manifest]
177
+ #
178
+ def merge(another)
179
+ if !another.is_a?(Mimi::Core::Manifest) && !another.is_a?(Hash)
180
+ raise ArgumentError 'Another Mimi::Core::Manifest or Hash is expected'
181
+ end
182
+ another_hash = another.is_a?(Hash) ? another.deep_dup : another.to_h.deep_dup
183
+ new_manifest_hash = @manifest.deep_merge(another_hash)
184
+ new_manifest_hash = manifest_hash_canonical(new_manifest_hash)
185
+ self.class.validate_manifest_hash(new_manifest_hash)
186
+ self.class.new(new_manifest_hash)
187
+ end
188
+
189
+ # Accepts the values, performs the validation and applies the manifest,
190
+ # responding with a Hash of parameters and processed values.
191
+ #
192
+ # Performs the type coercion of values to the specified configurable parameter type.
193
+ #
194
+ # * type: :string, value: anything => `String`
195
+ # * type: :integer, value: `1` or `'1'` => `1`
196
+ # * type: :decimal, value: `1`, `1.0 (BigDecimal)`, `'1'` or `'1.0'` => `1.0 (BigDecimal)`
197
+ # * type: :boolean, value: `true` or `'true'` => `true`
198
+ # * type: :json, value: `{ 'id' => 123 }` or `'{"id":123}'` => `{ 'id' => 123 }`
199
+ # * type: `['a', 'b', 'c']` , value: `'a'` => `'a'`
200
+ #
201
+ # Example:
202
+ #
203
+ # ```ruby
204
+ # manifest = Mimi::Core::Manifest.new(
205
+ # var1: {},
206
+ # var2: :integer,
207
+ # var3: :decimal,
208
+ # var4: :boolean,
209
+ # var5: :json,
210
+ # var6: ['a', 'b', 'c']
211
+ # )
212
+ #
213
+ # manifest.apply(
214
+ # var1: 'var1.value',
215
+ # var2: '2',
216
+ # var3: '3',
217
+ # var4: 'false',
218
+ # var5: '[{"name":"value"}]',
219
+ # var6: 'c'
220
+ # )
221
+ # # =>
222
+ # # {
223
+ # # var1: 'var1.value', var2: 2, var3: 3.0, var4: false,
224
+ # # var5: [{ 'name' => 'value '}], var6: 'c'
225
+ # # }
226
+ # ```
227
+ #
228
+ # If `:default` is specified for the parameter and the value is not provided,
229
+ # the default value is returned as-is, bypassing validation and type coercion.
230
+ #
231
+ # ```ruby
232
+ # manifest = Mimi::Core::Manifest.new(var1: { default: nil })
233
+ # manifest.apply({}) # => { var1: nil }
234
+ # ```
235
+ #
236
+ # Values for parameters not defined in the manifest are ignored:
237
+ #
238
+ # ```ruby
239
+ # manifest = Mimi::Core::Manifest.new(var1: {})
240
+ # manifest.apply(var1: '123', var2: '456') # => { var1: '123' }
241
+ # ```
242
+ #
243
+ # Configurable parameters defined as `:const` cannot be changed by provided values:
244
+ #
245
+ # ```ruby
246
+ # manifest = Mimi::Core::Manifest.new(var1: { default: 1, const: true })
247
+ # manifest.apply(var1: 2) # => { var1: 1 }
248
+ # ```
249
+ #
250
+ # If a configurable parameter defined as *required* in the manifest (has no `:default`)
251
+ # and the provided values have no corresponding key, an ArgumentError is raised:
252
+ #
253
+ # ```ruby
254
+ # manifest = Mimi::Core::Manifest.new(var1: {})
255
+ # manifest.apply({}) # => ArgumentError "Required value for 'var1' is missing"
256
+ # ```
257
+ #
258
+ # If a value provided for the configurable parameter is incompatible (different type,
259
+ # wrong format etc), an ArgumentError is raised:
260
+ #
261
+ # ```ruby
262
+ # manifest = Mimi::Core::Manifest.new(var1: { type: :integer })
263
+ # manifest.apply(var1: 'abc') # => ArgumentError "Invalid value provided for 'var1'"
264
+ # ```
265
+ #
266
+ # During validation of provided values, all violations are detected and reported in
267
+ # a single ArgumentError:
268
+ #
269
+ # ```ruby
270
+ # manifest = Mimi::Core::Manifest.new(var1: { type: :integer }, var2: {})
271
+ # manifest.apply(var1: 'abc') # =>
272
+ # # ArgumentError "Invalid value provided for 'var1'. Required value for 'var2' is missing."
273
+ # ```
274
+ #
275
+ # @param values [Hash]
276
+ # @return [Hash<Symbol,Object>] where key is the parameter name, value is the parameter value
277
+ #
278
+ # @raise [ArgumentError] on validation errors, missing values etc
279
+ #
280
+ def apply(values)
281
+ raise ArgumentError, 'Hash is expected as values' unless values.is_a?(Hash)
282
+ validate_values(values)
283
+ process_values(values)
284
+ end
285
+
286
+ # Validates a Hash representation of the manifest
287
+ #
288
+ # * all keys are symbols
289
+ # * all configurable parameter properties are valid
290
+ #
291
+ # @param manifest_hash [Hash]
292
+ # @raise [ArgumentError] if any part of manifest is invalid
293
+ #
294
+ def self.validate_manifest_hash(manifest_hash)
295
+ invalid_keys = manifest_hash.keys.reject { |k| k.is_a?(Symbol) }
296
+ unless invalid_keys.empty?
297
+ raise ArgumentError,
298
+ "Invalid manifest keys, Symbols are expected: #{invalid_keys.join(', ')}"
299
+ end
300
+
301
+ manifest_hash.each { |n, p| validate_manifest_key_properties(n, p) }
302
+ end
303
+
304
+ # Validates configurable parameter properties
305
+ #
306
+ # @param name [Symbol] name of the parameter
307
+ # @param properties [Hash] configurable parameter properties
308
+ #
309
+ def self.validate_manifest_key_properties(name, properties)
310
+ raise 'Hash as properties is expected' unless properties.is_a?(Hash)
311
+ if properties[:desc]
312
+ raise ArgumentError, 'String as :desc is expected' unless properties[:desc].is_a?(String)
313
+ end
314
+ if properties[:type]
315
+ if properties[:type].is_a?(Array)
316
+ if properties[:type].any? { |v| !v.is_a?(String) }
317
+ raise ArgumentError, 'Array<String> is expected as enumeration :type'
318
+ end
319
+ elsif !ALLOWED_TYPES.include?(properties[:type].to_s)
320
+ raise ArgumentError, "Unrecognised type '#{properties[:type]}'"
321
+ end
322
+ end
323
+ if properties.keys.include?(:hidden)
324
+ if !properties[:hidden].is_a?(TrueClass) && !properties[:hidden].is_a?(FalseClass)
325
+ raise ArgumentError, 'Invalid type for :hidden, true or false is expected'
326
+ end
327
+ end
328
+ if properties.keys.include?(:const)
329
+ if !properties[:const].is_a?(TrueClass) && !properties[:const].is_a?(FalseClass)
330
+ raise ArgumentError, 'Invalid type for :const, true or false is expected'
331
+ end
332
+ end
333
+ if properties[:const] && !properties.keys.include?(:default)
334
+ raise ArgumentError, ':default is required if :const is set'
335
+ end
336
+ rescue ArgumentError => e
337
+ raise ArgumentError, "Invalid manifest: invalid properties for '#{name}': #{e}"
338
+ end
339
+
340
+ # Constructs a Manifest object from a YAML representation
341
+ #
342
+ # @param yaml [String]
343
+ # @return [Mimi::Core::Manifest]
344
+ #
345
+ def self.from_yaml(yaml)
346
+ manifest_hash = YAML.safe_load(yaml)
347
+ raise 'Invalid manifest, JSON Object is expected' unless manifest_hash.is_a?(Hash)
348
+ manifest_hash = manifest_hash.map do |k, v|
349
+ v = (v || {}).symbolize_keys
350
+ [k.to_sym, v]
351
+ end.to_h
352
+ new(manifest_hash)
353
+ end
354
+
355
+ # Returns a YAML representation of the manifest
356
+ #
357
+ # @return [String]
358
+ #
359
+ def to_yaml
360
+ out = []
361
+ to_h.each do |k, v|
362
+ next if v[:hidden]
363
+ out << "#{k}:"
364
+ vy = v[:desc].nil? ? '# nil' : v[:desc].inspect # value to yaml
365
+ out << " desc: #{vy}" if v.key?(:desc) && !v[:desc].empty?
366
+ if v[:type].is_a?(Array)
367
+ out << ' type:'
368
+ v[:type].each { |t| out << " - #{t}" }
369
+ elsif v[:type] != :string
370
+ out << " type: #{v[:type]}"
371
+ end
372
+ out << ' const: true' if v[:const]
373
+ vy = v[:default].nil? ? '# nil' : v[:default].inspect # value to yaml
374
+ out << " default: #{vy}" if v.key?(:default)
375
+ out << ''
376
+ end
377
+ out.join("\n")
378
+ end
379
+
380
+ private
381
+
382
+ # Sets the missing default properties in the properties Hash, converts values
383
+ # to canonical form.
384
+ #
385
+ # @param properties [Hash] set of properties of a configurable parameter
386
+ # @return [Hash] same Hash with all the missing properties set
387
+ #
388
+ def properties_canonical(properties)
389
+ properties = {
390
+ desc: '',
391
+ type: :string,
392
+ hidden: false,
393
+ const: false
394
+ }.merge(properties)
395
+ properties[:desc] = properties[:desc].to_s
396
+ if properties[:type].is_a?(Array)
397
+ properties[:type] = properties[:type].map { |v| v.to_s }
398
+ elsif properties[:type].is_a?(String)
399
+ properties[:type] = properties[:type].to_sym
400
+ end
401
+ properties[:hidden] = !!properties[:hidden]
402
+ properties[:const] = !!properties[:const]
403
+ properties
404
+ end
405
+
406
+ # Converts a valid manifest Hash to a canonical form, with all defaults set
407
+ # and property values coerced:
408
+ #
409
+ # Example
410
+ #
411
+ # ```ruby
412
+ # manifest_hash_canonical(
413
+ # var1: {},
414
+ # var2: {
415
+ # type: 'string',
416
+ # hidden: nil,
417
+ # default: 1,
418
+ # const: false
419
+ # }
420
+ # )
421
+ # # =>
422
+ # # {
423
+ # # var1: { desc: '', type: :string, hidden: false, const: false },
424
+ # # var2: { desc: '', type: :string, default: 1, hidden: false, const: false }
425
+ # # }
426
+ #
427
+ # ```
428
+ #
429
+ # @param manifest_hash [Hash]
430
+ # @return [Hash]
431
+ #
432
+ def manifest_hash_canonical(manifest_hash)
433
+ manifest_hash.map do |name, props|
434
+ [name, properties_canonical(props)]
435
+ end.to_h
436
+ end
437
+
438
+ # Validates provided values
439
+ #
440
+ # @param values [Hash]
441
+ # @raise [ArgumentError] if any of the values are invalid or missing
442
+ #
443
+ def validate_values(values)
444
+ # select keys where value is required and missing
445
+ missing_values = @manifest.keys.select do |key|
446
+ required?(key) && values[key].nil?
447
+ end
448
+
449
+ # select keys where value is provided and invalid
450
+ invalid_values = @manifest.keys.select { |key| values[key] }.reject do |key|
451
+ type = @manifest[key][:type]
452
+ value = values[key]
453
+ case type
454
+ when :string
455
+ true # anything is valid
456
+ when :integer
457
+ value.is_a?(Integer) || (value.is_a?(String) && value =~ /^\d+$/)
458
+ when :decimal
459
+ value.is_a?(Integer) || value.is_a?(BigDecimal) ||
460
+ (value.is_a?(String) && value =~ /^\d+(\.\d+)?$/)
461
+ when :boolean
462
+ value.is_a?(TrueClass) || value.is_a?(FalseClass) ||
463
+ (value.is_a?(String) && value =~ /^(true|false)$/)
464
+ when :json
465
+ validate_value_json(value)
466
+ when Array
467
+ type.include?(value)
468
+ else
469
+ raise "Unexpected type '#{type}' for '#{key}'"
470
+ end
471
+ end
472
+ messages =
473
+ missing_values.map do |key|
474
+ "Required value for '#{key}' is missing."
475
+ end + invalid_values.map do |key|
476
+ "Invalid value provided for '#{key}'."
477
+ end
478
+ raise ArgumentError, messages.join(' ') unless messages.empty?
479
+ end
480
+
481
+ # Validates a single JSON value
482
+ #
483
+ # * must be a String
484
+ # * must be a valid JSON
485
+ #
486
+ # @param value [String]
487
+ # @return [true,false]
488
+ #
489
+ def validate_value_json(value)
490
+ return false unless value.is_a?(String)
491
+ JSON.parse(value)
492
+ true
493
+ rescue JSON::ParserError
494
+ false
495
+ end
496
+
497
+ # Processes the given set of values and returns a Hash of configurable parameter
498
+ # values.
499
+ #
500
+ # @param values [Hash]
501
+ # @return [Hash]
502
+ #
503
+ def process_values(values)
504
+ @manifest.map do |name, props|
505
+ [name, process_single_value(values[name], props)]
506
+ end.to_h
507
+ end
508
+
509
+ # Processes a single value with a given set of configurable parameter properties
510
+ #
511
+ # @param value [Object,nil] nil indicates the value is not provided (default should be used)
512
+ # @param properties [Hash]
513
+ # @return [Object]
514
+ #
515
+ def process_single_value(value, properties)
516
+ if properties[:const] || value.nil?
517
+ return properties[:default].is_a?(Proc) ? properties[:default].call : properties[:default]
518
+ end
519
+ case properties[:type]
520
+ when :string
521
+ value.to_s
522
+ when :integer
523
+ value.to_i
524
+ when :decimal
525
+ BigDecimal(value)
526
+ when :boolean
527
+ value.is_a?(TrueClass) || value == 'true'
528
+ when :json
529
+ JSON.parse(value)
530
+ when Array
531
+ value
532
+ else
533
+ raise "Unexpected type '#{type}' for '#{key}'"
534
+ end
535
+ end
536
+ end # class Manifest
537
+ end # module Core
538
+ end # module Mimi
@@ -1,48 +1,112 @@
1
- require 'active_support/concern'
1
+ # frozen_string_literal: true
2
2
 
3
3
  module Mimi
4
4
  module Core
5
5
  module Module
6
- extend ActiveSupport::Concern
7
-
8
- included do
9
- # register self
10
- Mimi.loaded_modules << self unless Mimi.loaded_modules.include?(self)
6
+ #
7
+ # Invoked on a descendant module declaration,
8
+ # registers a descendant module in the list of loaded modules.
9
+ #
10
+ def self.included(base)
11
+ return if Mimi.loaded_modules.include?(base)
12
+ Mimi.loaded_modules << base
13
+ base.send :extend, ClassMethods
11
14
  end
12
15
 
13
- class_methods do
16
+ module ClassMethods
17
+ #
18
+ # Processes given values for configurable parameters defined in the module manifest
19
+ # and populates the options Hash.
20
+ #
21
+ # @param opts [Hash] values for configurable parameters
22
+ #
14
23
  def configure(opts = {})
15
- @module_options = (@module_default_options || {}).deep_merge(opts)
24
+ manifest_hash = manifest
25
+ unless manifest_hash.is_a?(Hash)
26
+ raise "#{self}.manifest should be implemented and return Hash"
27
+ end
28
+ @options = Mimi::Core::Manifest.new(manifest_hash).apply(opts)
16
29
  end
17
30
 
31
+ # Returns the path to module files, if the module exposes any files.
32
+ #
33
+ # Some modules may expose its files to the application using Mimi core. For example,
34
+ # a module may contain some rake tasks with useful functionality.
35
+ #
36
+ # To expose module files, this method must be overloaded and point to the root
37
+ # of the gem folder:
38
+ #
39
+ # ```
40
+ # # For example, module my_lib folder and files:
41
+ # /path/to/my_lib/
42
+ # ./lib/my_lib/...
43
+ # ./lib/my_lib.rb
44
+ # ./spec/spec_helper
45
+ # ...
46
+ #
47
+ # # my_lib module should expose its root as .module_path: /path/to/my_lib
48
+ # ```
49
+ #
50
+ # @return [Pathname,String,nil]
51
+ #
18
52
  def module_path
19
53
  nil
20
54
  end
21
55
 
22
- def module_manifest
56
+ # Module manifest
57
+ #
58
+ # Mimi modules overload this method to define their own set of configurable parameters.
59
+ # The method should return a Hash representation of the manifest.
60
+ #
61
+ # NOTE: to avoid clashes with other modules, it is advised that configurable parameters
62
+ # for the module have some module-specific prefix. E.g. `Mimi::DB` module has its
63
+ # configurable parameters names as `db_adapter`, `db_database`, `db_username` and so on.
64
+ #
65
+ # @see Mimi::Core::Manifest
66
+ #
67
+ # @return [Hash]
68
+ #
69
+ def manifest
23
70
  {}
24
71
  end
25
72
 
73
+ # Starts the module.
74
+ #
75
+ # Mimi modules overload this method to implement some module-specific logic that
76
+ # should happen on application startup. E.g. `mimi-messaging` establishes a connection
77
+ # with a message broker and declares message consumers.
78
+ #
26
79
  def start(*)
27
80
  @module_started = true
28
81
  end
29
82
 
83
+ # Returns true if the module is started.
84
+ #
85
+ # @return [true,false]
86
+ #
30
87
  def started?
31
88
  @module_started
32
89
  end
33
90
 
91
+ # Starts the module.
92
+ #
93
+ # Mimi modules overload this method to implement some module-specific logic that
94
+ # should happen on application shutdown. E.g. `mimi-messaging` closes a connection
95
+ # with a message broker.
96
+ #
34
97
  def stop(*)
35
98
  @module_started = false
36
99
  end
37
100
 
38
- def module_options
39
- @module_options || @module_default_options || {}
40
- end
41
-
42
- def default_options(opts = {})
43
- @module_default_options = opts
101
+ # Returns a Hash of configurable parameter values accepted and set by the `.configure`
102
+ # method.
103
+ #
104
+ # @return [Hash<Symbol,Object>]
105
+ #
106
+ def options
107
+ @options || {}
44
108
  end
45
- end
109
+ end # module ClassMethods
46
110
  end # module Module
47
111
  end # module Core
48
112
  end # module Mimi