kanon 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/docs/decisions.md +176 -0
- data/docs/specification.md +333 -0
- data/kanon.gemspec +37 -0
- data/lib/kanon/document.rb +103 -0
- data/lib/kanon/env_declarations.rb +140 -0
- data/lib/kanon/errors.rb +16 -0
- data/lib/kanon/node.rb +121 -0
- data/lib/kanon/typecast.rb +76 -0
- data/lib/kanon/version.rb +5 -0
- data/lib/kanon.rb +113 -0
- metadata +68 -0
data/lib/kanon/node.rb
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kanon
|
|
4
|
+
class Node
|
|
5
|
+
def self.build(value)
|
|
6
|
+
case value
|
|
7
|
+
when Hash then new(value.each_with_object({}) { |(key, nested), result| result[key_for(key)] = build(nested) })
|
|
8
|
+
when Array then value.map { |nested| build(nested) }.freeze
|
|
9
|
+
else value
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.key_for(key)
|
|
14
|
+
key.respond_to?(:to_sym) ? key.to_sym : key
|
|
15
|
+
end
|
|
16
|
+
private_class_method :key_for
|
|
17
|
+
|
|
18
|
+
def initialize(values)
|
|
19
|
+
@values = values.freeze
|
|
20
|
+
reject_reserved_keys
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def [](key)
|
|
25
|
+
@values.fetch(lookup_key(key)) { raise UnknownKey, "no key #{key} here, keys: #{keys.join(', ')}" }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def key?(key)
|
|
29
|
+
@values.key?(lookup_key(key))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def keys
|
|
33
|
+
@values.keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dig(*path)
|
|
37
|
+
path.inject(self) do |level, key|
|
|
38
|
+
case level
|
|
39
|
+
when Node then level[key]
|
|
40
|
+
when Array then dig_into_array(level, key)
|
|
41
|
+
else raise UnknownKey, "no key #{key} inside a #{level.class}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
@values.each_with_object({}) { |(key, value), result| result[key] = self.class.unwrap(value) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.unwrap(value)
|
|
51
|
+
case value
|
|
52
|
+
when Node then value.to_h
|
|
53
|
+
when Array then value.map { |nested| unwrap(nested) }
|
|
54
|
+
else value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ==(other)
|
|
59
|
+
other.is_a?(Node) && to_h == other.to_h
|
|
60
|
+
end
|
|
61
|
+
alias eql? ==
|
|
62
|
+
|
|
63
|
+
def hash
|
|
64
|
+
to_h.hash
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def inspect
|
|
68
|
+
"#<Kanon::Node keys: #{keys.join(', ')}>"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def as_json(*)
|
|
72
|
+
inspect
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_json(*)
|
|
76
|
+
require 'json'
|
|
77
|
+
JSON.generate(as_json)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def encode_with(coder)
|
|
81
|
+
coder.represent_scalar(nil, inspect)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def instance_variables
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def marshal_dump
|
|
89
|
+
raise TypeError, "#{inspect} refuses serialization that would expose its values"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def method_missing(name, *arguments)
|
|
95
|
+
return @values[name] if arguments.empty? && @values.key?(name)
|
|
96
|
+
|
|
97
|
+
raise NoMethodError, "undefined method `#{name}' for #{inspect}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def respond_to_missing?(name, include_private = false)
|
|
101
|
+
@values.key?(name.to_sym) || super
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def dig_into_array(array, key)
|
|
105
|
+
raise UnknownKey, "no index #{key} in an Array of #{array.size}" unless key.is_a?(Integer)
|
|
106
|
+
|
|
107
|
+
array.fetch(key) { raise UnknownKey, "no index #{key} in an Array of #{array.size}" }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def lookup_key(key)
|
|
111
|
+
key.respond_to?(:to_sym) ? key.to_sym : key
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reject_reserved_keys
|
|
115
|
+
reserved = keys.select { |key| key.is_a?(Symbol) && self.class.method_defined?(key) }
|
|
116
|
+
return if reserved.empty?
|
|
117
|
+
|
|
118
|
+
raise ReservedKey, "#{reserved.join(', ')} clash with node methods and cannot be config keys"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kanon
|
|
4
|
+
module Typecast
|
|
5
|
+
TRUE_VALUES = %w[1 true yes on].freeze
|
|
6
|
+
FALSE_VALUES = %w[0 false no off].freeze
|
|
7
|
+
TYPE_NAMES = %w[string integer float boolean list].freeze
|
|
8
|
+
|
|
9
|
+
private_constant :TRUE_VALUES, :FALSE_VALUES
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def like_sample(raw_value, sample, source)
|
|
14
|
+
case sample
|
|
15
|
+
when true, false then boolean(raw_value, source)
|
|
16
|
+
when Integer then integer(raw_value, source)
|
|
17
|
+
when Float then float(raw_value, source)
|
|
18
|
+
when Array then list(raw_value)
|
|
19
|
+
else raw_value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def by_type_name(raw_value, type_name, source)
|
|
24
|
+
reject_unknown_type(type_name, source)
|
|
25
|
+
|
|
26
|
+
case type_name.to_s
|
|
27
|
+
when 'string' then raw_value.to_s
|
|
28
|
+
when 'integer' then integer(raw_value, source)
|
|
29
|
+
when 'float' then float(raw_value, source)
|
|
30
|
+
when 'boolean' then boolean(raw_value, source)
|
|
31
|
+
when 'list' then list(raw_value)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reject_unknown_type(type_name, source)
|
|
36
|
+
return if TYPE_NAMES.include?(type_name.to_s)
|
|
37
|
+
|
|
38
|
+
raise InvalidValue, "#{source} declares unknown type #{type_name.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def boolean(raw_value, source)
|
|
42
|
+
return true if TRUE_VALUES.include?(raw_value.to_s.downcase)
|
|
43
|
+
return false if FALSE_VALUES.include?(raw_value.to_s.downcase)
|
|
44
|
+
|
|
45
|
+
raise InvalidValue,
|
|
46
|
+
"#{source} must be one of #{(TRUE_VALUES + FALSE_VALUES).join(', ')}, got #{describe(raw_value)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A list that leaves no items counts as no value, so a mandatory one fails
|
|
50
|
+
# rather than arriving empty.
|
|
51
|
+
def list(raw_value)
|
|
52
|
+
return raw_value if raw_value.is_a?(Array)
|
|
53
|
+
|
|
54
|
+
items = raw_value.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
55
|
+
items.empty? ? nil : items
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def describe(raw_value)
|
|
59
|
+
"#{raw_value.class} of #{raw_value.to_s.length} characters"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The base is explicit: without it a leading zero would be read as octal, and
|
|
63
|
+
# port 010 would turn into 8.
|
|
64
|
+
def integer(raw_value, source)
|
|
65
|
+
raw_value.is_a?(Integer) ? raw_value : Integer(raw_value.to_s, 10)
|
|
66
|
+
rescue ArgumentError, TypeError
|
|
67
|
+
raise InvalidValue, "#{source} must be an integer, got #{describe(raw_value)}", cause: nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def float(raw_value, source)
|
|
71
|
+
Float(raw_value)
|
|
72
|
+
rescue ArgumentError, TypeError
|
|
73
|
+
raise InvalidValue, "#{source} must be a number, got #{describe(raw_value)}", cause: nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/kanon.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'erb'
|
|
6
|
+
require_relative 'kanon/version'
|
|
7
|
+
require_relative 'kanon/errors'
|
|
8
|
+
require_relative 'kanon/document'
|
|
9
|
+
require_relative 'kanon/node'
|
|
10
|
+
require_relative 'kanon/typecast'
|
|
11
|
+
require_relative 'kanon/env_declarations'
|
|
12
|
+
|
|
13
|
+
module Kanon
|
|
14
|
+
DEFAULT_DIRECTORY = 'config'
|
|
15
|
+
DEFAULT_ENVIRONMENT = 'development'
|
|
16
|
+
ENVIRONMENT_VARIABLES = %w[APP_ENV RAILS_ENV RACK_ENV].freeze
|
|
17
|
+
|
|
18
|
+
@monitor = Monitor.new
|
|
19
|
+
@storage = {}
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def directory
|
|
23
|
+
@monitor.synchronize { @directory ||= DEFAULT_DIRECTORY }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def directory=(path)
|
|
27
|
+
raise EmptyDirectory, 'configuration directory cannot be empty' if blank?(path)
|
|
28
|
+
|
|
29
|
+
@monitor.synchronize do
|
|
30
|
+
@directory = path.to_s.strip
|
|
31
|
+
clear
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def environment
|
|
36
|
+
@monitor.synchronize { @environment ||= given_environment || DEFAULT_ENVIRONMENT }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def environment=(name)
|
|
40
|
+
raise EmptyEnvironment, 'environment name cannot be empty' if blank?(name)
|
|
41
|
+
|
|
42
|
+
@monitor.synchronize do
|
|
43
|
+
@environment = name.to_s.strip
|
|
44
|
+
clear
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def read_config(file_name)
|
|
49
|
+
data = build(file_name.to_s.to_sym)
|
|
50
|
+
data = { file_name.to_s.to_sym => data } unless data.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
Kanon::Node.build(data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](file_name)
|
|
56
|
+
load_config(file_name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load_config(file_name)
|
|
60
|
+
name = file_name.to_s.to_sym
|
|
61
|
+
|
|
62
|
+
@monitor.synchronize { @storage[name] ||= read_config(name) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_configs(*file_names)
|
|
66
|
+
file_names.to_h { |file_name| [file_name.to_s.to_sym, load_config(file_name)] }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def loaded_files
|
|
70
|
+
@monitor.synchronize { @storage.keys }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def expected_variables(file_name)
|
|
74
|
+
declarations, schema = prepare(file_name.to_s.to_sym)
|
|
75
|
+
|
|
76
|
+
declarations.expected(schema)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def clear
|
|
82
|
+
@monitor.synchronize { @storage.clear }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def given_environment
|
|
86
|
+
ENVIRONMENT_VARIABLES.map { |name| ENV.fetch(name, nil) }.find { |value| !blank?(value) }&.strip
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def blank?(value)
|
|
90
|
+
value.nil? || value.to_s.strip.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build(file_name)
|
|
94
|
+
declarations, schema = prepare(file_name)
|
|
95
|
+
|
|
96
|
+
deep_freeze(declarations.resolve(schema))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def prepare(file_name)
|
|
100
|
+
prefix, schema = Kanon::Document.new(directory, file_name, environment).schema
|
|
101
|
+
|
|
102
|
+
[Kanon::EnvDeclarations.new(file_name, prefix), schema]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def deep_freeze(value)
|
|
106
|
+
case value
|
|
107
|
+
when Hash then value.each_value { |nested| deep_freeze(nested) }.freeze
|
|
108
|
+
when Array then value.each { |nested| deep_freeze(nested) }.freeze
|
|
109
|
+
else value.freeze
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: kanon
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Maksimenko Pavel
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
A YAML file becomes a frozen tree of nodes with dotted access, and the values
|
|
15
|
+
that belong to the environment are declared in that same file. A read that
|
|
16
|
+
misses a mandatory value fails naming every absent variable at once, and a
|
|
17
|
+
typo in a key raises instead of returning nil. ERB tags keep working, so a
|
|
18
|
+
file that already reads the environment that way needs no rewriting. The
|
|
19
|
+
standard library only, with the config directory and the environment name
|
|
20
|
+
injected from outside.
|
|
21
|
+
email:
|
|
22
|
+
- pavel.g.maksimenko@gmail.com
|
|
23
|
+
executables: []
|
|
24
|
+
extensions: []
|
|
25
|
+
extra_rdoc_files: []
|
|
26
|
+
files:
|
|
27
|
+
- CHANGELOG.md
|
|
28
|
+
- LICENSE
|
|
29
|
+
- README.md
|
|
30
|
+
- docs/decisions.md
|
|
31
|
+
- docs/specification.md
|
|
32
|
+
- kanon.gemspec
|
|
33
|
+
- lib/kanon.rb
|
|
34
|
+
- lib/kanon/document.rb
|
|
35
|
+
- lib/kanon/env_declarations.rb
|
|
36
|
+
- lib/kanon/errors.rb
|
|
37
|
+
- lib/kanon/node.rb
|
|
38
|
+
- lib/kanon/typecast.rb
|
|
39
|
+
- lib/kanon/version.rb
|
|
40
|
+
homepage: https://github.com/MaksimenkoPG/kanon
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
homepage_uri: https://github.com/MaksimenkoPG/kanon
|
|
45
|
+
source_code_uri: https://github.com/MaksimenkoPG/kanon
|
|
46
|
+
changelog_uri: https://github.com/MaksimenkoPG/kanon/blob/master/CHANGELOG.md
|
|
47
|
+
rubygems_mfa_required: 'true'
|
|
48
|
+
post_install_message:
|
|
49
|
+
rdoc_options: []
|
|
50
|
+
require_paths:
|
|
51
|
+
- lib
|
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '2.7'
|
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0'
|
|
62
|
+
requirements: []
|
|
63
|
+
rubygems_version: 3.1.6
|
|
64
|
+
signing_key:
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: A YAML config becomes a frozen tree of nodes, and declares the environment
|
|
67
|
+
variables it needs
|
|
68
|
+
test_files: []
|