mimi-core 0.2.0 → 1.0.0

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,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