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
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Kanon: the specification
|
|
2
|
+
|
|
3
|
+
What an implementation must do, in any language. The reasons behind these rules
|
|
4
|
+
are in [decisions.md](decisions.md); the Ruby interface is in the
|
|
5
|
+
[README](../README.md).
|
|
6
|
+
|
|
7
|
+
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT,
|
|
8
|
+
RECOMMENDED, MAY and OPTIONAL in this document are to be interpreted as described
|
|
9
|
+
in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html). They carry that
|
|
10
|
+
meaning only in capitals.
|
|
11
|
+
|
|
12
|
+
## 1. What this describes
|
|
13
|
+
|
|
14
|
+
A **configuration** is one text file. Reading it yields a **tree**: mappings,
|
|
15
|
+
sequences and scalars, where some scalars came from the file and some came from
|
|
16
|
+
outside it. The file says which ones come from outside, and under what names.
|
|
17
|
+
|
|
18
|
+
Terms used below:
|
|
19
|
+
|
|
20
|
+
- **document** — what the file parses to before anything is done with it.
|
|
21
|
+
- **node** — a mapping in the result tree, as the consumer sees it.
|
|
22
|
+
- **leaf** — a scalar position, or a declaration standing in for one.
|
|
23
|
+
- **path** — the keys and indices from the root of the document down to a leaf.
|
|
24
|
+
- **declaration** — a mapping that describes a value instead of being one.
|
|
25
|
+
- **source** — a lookup from a name to text or nothing. The reference
|
|
26
|
+
implementation reads the process environment.
|
|
27
|
+
- **refusal** — a named failure. The names in section 11 are normative, how an
|
|
28
|
+
implementation represents them is not.
|
|
29
|
+
|
|
30
|
+
## 2. Settings
|
|
31
|
+
|
|
32
|
+
An implementation MUST take two settings from its caller and MUST NOT discover
|
|
33
|
+
them any other way.
|
|
34
|
+
|
|
35
|
+
**directory** — where configuration files live, `config` until set.
|
|
36
|
+
|
|
37
|
+
**environment** — which section to read. When the caller does not set it, an
|
|
38
|
+
implementation MUST take the first of `APP_ENV`, `RAILS_ENV`, `RACK_ENV` that is
|
|
39
|
+
not empty, and MUST fall back to `development` when none is.
|
|
40
|
+
|
|
41
|
+
Throughout this document, **empty** means absent, or text holding nothing once
|
|
42
|
+
surrounding whitespace is removed. Setting either to nothing MUST be refused, as
|
|
43
|
+
`EmptyDirectory` and `EmptyEnvironment`, and what is stored MUST have surrounding
|
|
44
|
+
whitespace removed. Both settings are process-wide, and changing either MUST empty
|
|
45
|
+
the cache of section 13.
|
|
46
|
+
|
|
47
|
+
## 3. Locating the file
|
|
48
|
+
|
|
49
|
+
A configuration is addressed by name. A name MUST consist only of lowercase
|
|
50
|
+
letters, digits and underscores; anything else MUST be refused as `FileMissing`
|
|
51
|
+
without touching the filesystem, which also forbids paths.
|
|
52
|
+
|
|
53
|
+
The file is `<directory>/<name>.yml`. When no such file exists the refusal is
|
|
54
|
+
`FileMissing`. When it exists but cannot be opened — it is a directory, or the
|
|
55
|
+
process may not read it — the refusal is `InvalidSource`.
|
|
56
|
+
|
|
57
|
+
## 4. Reading the document
|
|
58
|
+
|
|
59
|
+
The file is turned into a document in three steps, in this order.
|
|
60
|
+
|
|
61
|
+
**4.1 Template pass.** The text is run through a template pass before it is
|
|
62
|
+
parsed. The pass MAY build the structure of the file, not only its values, so a
|
|
63
|
+
conditional MAY decide whether a key exists at all. When the pass fails, the
|
|
64
|
+
refusal is `InvalidSource` and the message MUST carry only the kind of failure,
|
|
65
|
+
never the text that failed.
|
|
66
|
+
|
|
67
|
+
The template language is implementation-defined; the reference implementation
|
|
68
|
+
uses ERB. **This is the one part of a configuration that does not port.** A file
|
|
69
|
+
carrying template tags is tied to the implementation that reads it, and an
|
|
70
|
+
implementation MUST document which language it accepts.
|
|
71
|
+
|
|
72
|
+
**4.2 Parse.** The result of the template pass is parsed as YAML.
|
|
73
|
+
|
|
74
|
+
- Only the first document MUST be read. A `---` separator starts a second one,
|
|
75
|
+
and everything past it is dropped.
|
|
76
|
+
- Aliases and merge keys MUST be resolved, `<<: *anchor` included.
|
|
77
|
+
- The permitted values are text, numbers, booleans, nothing, mappings and
|
|
78
|
+
sequences. Anything else the parser would build — a timestamp, a
|
|
79
|
+
language-specific object — MUST be refused as `InvalidSource`.
|
|
80
|
+
- A malformed document, and a document referring to itself through an alias, MUST
|
|
81
|
+
be refused as `InvalidSource`. An implementation MUST NOT let a self-referring
|
|
82
|
+
document exhaust its stack.
|
|
83
|
+
|
|
84
|
+
**4.3 Key normalisation.** A key written as an unquoted scalar beginning with a
|
|
85
|
+
colon names the same key as that text without the colon, so `token:` and `:token:`
|
|
86
|
+
are one key. A key written in quotes names exactly its text, so `":token":` is a
|
|
87
|
+
third, different key. Two keys in one mapping that normalise to one name MUST be
|
|
88
|
+
refused as `InvalidSource`, naming both spellings.
|
|
89
|
+
|
|
90
|
+
The distinction rests on quoting, which a parser knows but a plain mapping of the
|
|
91
|
+
parse result no longer carries. An implementation whose parser discards it MUST
|
|
92
|
+
document that a quoted key beginning with a colon is not supported, rather than
|
|
93
|
+
quietly merging it with the unquoted spelling.
|
|
94
|
+
|
|
95
|
+
Keys that are not text at all — a number, a boolean, a sequence used as a key —
|
|
96
|
+
are kept as they are. They are reachable by computed access but never by a
|
|
97
|
+
literal one, and a variable name derived through one is refused by section 8.
|
|
98
|
+
|
|
99
|
+
## 5. Choosing the section
|
|
100
|
+
|
|
101
|
+
If the document is a mapping and its top level holds a key equal to the
|
|
102
|
+
environment name, the document MUST be narrowed to the value under that key.
|
|
103
|
+
Otherwise the document MUST be read whole. A file with no sections is therefore
|
|
104
|
+
read as it stands, and nothing forces a section onto a list or a dictionary.
|
|
105
|
+
|
|
106
|
+
A narrowed section holding nothing MUST be read as an empty mapping.
|
|
107
|
+
|
|
108
|
+
## 6. The root key
|
|
109
|
+
|
|
110
|
+
When the document is a mapping with exactly one key, and that key holds a mapping
|
|
111
|
+
which is not a declaration, the key MUST be removed from the data and MUST become
|
|
112
|
+
the first segment of every variable name derived inside it.
|
|
113
|
+
|
|
114
|
+
In every other case the single key stays in the data: over a sequence, over a
|
|
115
|
+
scalar, and over a declaration. It is then an ordinary path segment, so the names
|
|
116
|
+
derived below it are the same either way — only the reading path differs.
|
|
117
|
+
|
|
118
|
+
When the document is a sequence, the **file name** MUST become the first segment
|
|
119
|
+
of every variable name derived inside it, and the result MUST be wrapped in a
|
|
120
|
+
mapping under that same name.
|
|
121
|
+
|
|
122
|
+
## 7. Declarations
|
|
123
|
+
|
|
124
|
+
A declaration is a mapping that describes a value. Its vocabulary is exactly four
|
|
125
|
+
keys:
|
|
126
|
+
|
|
127
|
+
- **`env`** — the variable name to read, in place of the derived one.
|
|
128
|
+
- **`default`** — what the value is when the source holds nothing.
|
|
129
|
+
- **`type`** — how text from the source becomes a value.
|
|
130
|
+
- **`optional`** — whether the value may be absent.
|
|
131
|
+
|
|
132
|
+
A mapping MUST be treated as a declaration when it holds `env`, or when it holds
|
|
133
|
+
at least one vocabulary key and no other key. Any other mapping is data.
|
|
134
|
+
|
|
135
|
+
A declaration holding a key outside the vocabulary MUST be refused as
|
|
136
|
+
`InvalidDeclaration`, naming the unwanted keys. A mapping carrying `env` is a
|
|
137
|
+
declaration on that key alone, so its other keys are refused rather than keeping
|
|
138
|
+
it as data.
|
|
139
|
+
|
|
140
|
+
`optional` permits a value to be **absent**. Only the value `true` counts, never
|
|
141
|
+
the text `"true"`. An empty string written as `default` is a value, not an
|
|
142
|
+
absence, and reaches the consumer.
|
|
143
|
+
|
|
144
|
+
## 8. Deriving a variable name
|
|
145
|
+
|
|
146
|
+
A derived name is built from the path: the prefix from section 6, then every key
|
|
147
|
+
and index down to the leaf. Each segment is rendered as text and upper-cased, and
|
|
148
|
+
the segments are joined with a single underscore.
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
:service: { :pool: { :size: ~ } } → SERVICE_POOL_SIZE
|
|
152
|
+
:service: { :queues: [ { :name: ~ } ] } → SERVICE_QUEUES_0_NAME
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A sequence index is a segment like any key, which means a name records a
|
|
156
|
+
**position**. Inserting an element renames every name below it.
|
|
157
|
+
|
|
158
|
+
A declaration holding `env` uses that name instead, whatever the path.
|
|
159
|
+
|
|
160
|
+
Every name, derived or written out, MUST match `[A-Za-z_][A-Za-z0-9_]*`; anything
|
|
161
|
+
else MUST be refused as `InvalidVariableName`.
|
|
162
|
+
|
|
163
|
+
Two leaves MUST NOT read one name. The second MUST be refused as
|
|
164
|
+
`ConflictingVariable`, naming both paths.
|
|
165
|
+
|
|
166
|
+
## 9. Resolving a leaf
|
|
167
|
+
|
|
168
|
+
A scalar that holds a value in the file is a constant: it MUST NOT consult the
|
|
169
|
+
source, and its name MUST NOT appear in the report of section 14.
|
|
170
|
+
|
|
171
|
+
Every other leaf — an empty scalar, or a declaration — resolves in this order:
|
|
172
|
+
|
|
173
|
+
1. Determine the name (section 8). A name outside the permitted shape is refused
|
|
174
|
+
here, before anything else about the leaf is examined.
|
|
175
|
+
2. Refuse a declaration holding keys outside the vocabulary.
|
|
176
|
+
3. Refuse a declaration whose `type` is not one of the five names of section 10.
|
|
177
|
+
A `type` key written and left empty is refused too, rather than ignored.
|
|
178
|
+
4. Claim the name, refusing a second leaf reading it.
|
|
179
|
+
5. Look the name up in the source. Text holding nothing counts as no value; text
|
|
180
|
+
of whitespace only counts as a value.
|
|
181
|
+
6. Cast what was found, or the `default` when nothing was found (section 10).
|
|
182
|
+
7. When the result is nothing and the leaf is not `optional`, record the name as
|
|
183
|
+
missing.
|
|
184
|
+
|
|
185
|
+
After the whole document has been walked, if any names were recorded missing the
|
|
186
|
+
read MUST be refused as `MissingValue` **naming all of them at once**. An
|
|
187
|
+
implementation MUST NOT stop at the first.
|
|
188
|
+
|
|
189
|
+
A leaf that is `optional` and resolves to nothing MUST appear in the tree with an
|
|
190
|
+
empty value rather than being dropped.
|
|
191
|
+
|
|
192
|
+
## 10. Casting
|
|
193
|
+
|
|
194
|
+
No value is no value whatever produced it: a `~` in the file, a template tag that
|
|
195
|
+
expanded to nothing, and a variable that was never set are the same.
|
|
196
|
+
|
|
197
|
+
**With an explicit `type`**, one of five:
|
|
198
|
+
|
|
199
|
+
- **`string`** — rendered as text.
|
|
200
|
+
- **`integer`** — read in base ten, so `010` is ten and never eight, and `0x10` is
|
|
201
|
+
refused rather than read as sixteen.
|
|
202
|
+
- **`float`** — read as a number, so `1e3` is `1000.0`.
|
|
203
|
+
- **`boolean`** — `1`, `true`, `yes` and `on` are true; `0`, `false`, `no` and
|
|
204
|
+
`off` are false; letter case is ignored. Anything else is refused, naming what
|
|
205
|
+
is accepted.
|
|
206
|
+
- **`list`** — split on commas, each item trimmed, empty items dropped. A value
|
|
207
|
+
that leaves no items counts as nothing, so a mandatory list fails rather than
|
|
208
|
+
falling back to its default.
|
|
209
|
+
|
|
210
|
+
A value that does not fit its type MUST be refused as `InvalidValue`. The message
|
|
211
|
+
MUST carry the type, the kind of the value and its length — never the value.
|
|
212
|
+
|
|
213
|
+
**Without an explicit `type`**, the kind of `default` decides: a whole number
|
|
214
|
+
casts as `integer`, a number with a fraction as `float`, `true` or `false` as
|
|
215
|
+
`boolean`, a sequence as `list`. A `default` that is text imposes nothing, and
|
|
216
|
+
whatever the source held arrives unchanged.
|
|
217
|
+
|
|
218
|
+
Either way a `default` MUST be cast exactly as a value from the source is, so
|
|
219
|
+
`{ default: "5", type: integer }` yields the number five.
|
|
220
|
+
|
|
221
|
+
## 11. Refusals
|
|
222
|
+
|
|
223
|
+
Every refusal below MUST be distinguishable by the consumer, and an implementation
|
|
224
|
+
SHOULD group them under one kind so that a single catch covers the reading of a
|
|
225
|
+
file.
|
|
226
|
+
|
|
227
|
+
- **`FileMissing`** — the file is absent, or the name is not a bare file name.
|
|
228
|
+
- **`InvalidSource`** — the file cannot be opened, its template pass failed, it
|
|
229
|
+
cannot be parsed, it holds a forbidden value, it refers to itself, or it holds
|
|
230
|
+
two keys that normalise to one.
|
|
231
|
+
- **`EmptyDirectory`** — the directory was set to nothing.
|
|
232
|
+
- **`EmptyEnvironment`** — the environment was set to nothing.
|
|
233
|
+
- **`MissingValue`** — mandatory values are absent, all named at once.
|
|
234
|
+
- **`InvalidValue`** — a value does not fit its type, or a declaration names a
|
|
235
|
+
type outside the five.
|
|
236
|
+
- **`InvalidDeclaration`** — a declaration carries a key outside the vocabulary.
|
|
237
|
+
- **`InvalidVariableName`** — a name, derived or written out, is not a usable
|
|
238
|
+
variable name.
|
|
239
|
+
- **`ConflictingVariable`** — two paths read one name.
|
|
240
|
+
- **`UnknownKey`** — a key that is not in the node, an index past the end of a
|
|
241
|
+
sequence, a descent into a scalar.
|
|
242
|
+
- **`ReservedKey`** — a key that would shadow a member of the node itself.
|
|
243
|
+
|
|
244
|
+
An implementation MUST NOT let a failure of the host language escape in place of
|
|
245
|
+
one of these while reading a file.
|
|
246
|
+
|
|
247
|
+
## 12. The result
|
|
248
|
+
|
|
249
|
+
**One shape.** A consumer MUST NOT be able to tell which values came from the
|
|
250
|
+
file and which from the source.
|
|
251
|
+
|
|
252
|
+
**A node, never a plain mapping.** A reader MUST hand back a node offering access
|
|
253
|
+
by a key written out in the code, access by a key computed at runtime, a descent
|
|
254
|
+
by path, a test for a key, a list of its keys, and a copy as an ordinary mapping
|
|
255
|
+
for a third party that needs one. It MUST NOT pretend to be an ordinary mapping of
|
|
256
|
+
the host language.
|
|
257
|
+
|
|
258
|
+
**A key that is not there is a refusal.** Reading an absent key MUST refuse,
|
|
259
|
+
naming the keys that are present; returning nothing MUST NOT happen. A key
|
|
260
|
+
computed at runtime refuses as `UnknownKey`. A key written out in the code MAY
|
|
261
|
+
instead refuse the way the host language refuses an absent member, as long as the
|
|
262
|
+
message names the keys that are there.
|
|
263
|
+
|
|
264
|
+
**Reserved keys.** If nodes expose named members, a key clashing with one MUST be
|
|
265
|
+
refused as `ReservedKey` when the tree is built, rather than shadowed at read
|
|
266
|
+
time.
|
|
267
|
+
|
|
268
|
+
**Immutable.** The tree MUST be deeply immutable, down to the scalars inside
|
|
269
|
+
sequences. The copy handed out as an ordinary mapping is new and mutable, while
|
|
270
|
+
the scalars inside it remain the immutable ones of the tree.
|
|
271
|
+
|
|
272
|
+
**Diagnostics carry no values.** Inspection, serialisation and the refusals of
|
|
273
|
+
section 11 MUST carry names — of keys, of variables, of declared types — together
|
|
274
|
+
with kinds and lengths, and MUST NOT carry a value read from the source or held in
|
|
275
|
+
the file as data. A name is printed whatever produced it, a template tag included.
|
|
276
|
+
A refusal that wraps a lower failure MUST NOT carry that failure along.
|
|
277
|
+
|
|
278
|
+
## 13. Caching
|
|
279
|
+
|
|
280
|
+
A configuration is read once per process and kept. Repeated reading through the
|
|
281
|
+
caching reader MUST hand back the same tree, not an equal one. A separate reader
|
|
282
|
+
that re-reads the file every time MUST leave the cache alone, and its result MUST
|
|
283
|
+
be compared by value.
|
|
284
|
+
|
|
285
|
+
Reading is lazy: a configuration the application never asks for MUST NOT be read.
|
|
286
|
+
Changing either setting of section 2 MUST empty the cache. A reader taking several
|
|
287
|
+
names at once MUST cache the trees, not the collection it returns, and MUST
|
|
288
|
+
rebuild that collection on every call.
|
|
289
|
+
|
|
290
|
+
## 14. Reporting expected variables
|
|
291
|
+
|
|
292
|
+
An implementation MUST offer a report of the names a configuration reads and
|
|
293
|
+
whether each must be provided, without consulting the source.
|
|
294
|
+
|
|
295
|
+
The report MUST be produced by walking the file through the same code a read
|
|
296
|
+
walks, with an empty source in place of the real one.
|
|
297
|
+
|
|
298
|
+
A name is reported as required when, with an empty source, the leaf resolves to
|
|
299
|
+
nothing and is not `optional`.
|
|
300
|
+
|
|
301
|
+
The report walks the document but does not build the tree, so it raises every
|
|
302
|
+
refusal of section 11 except two: `MissingValue`, which an empty source would
|
|
303
|
+
raise for every name, and `ReservedKey`, which is found only while the tree is
|
|
304
|
+
built.
|
|
305
|
+
|
|
306
|
+
## 15. Out of scope
|
|
307
|
+
|
|
308
|
+
An implementation MUST NOT extend beyond these.
|
|
309
|
+
|
|
310
|
+
- **Not a validator.** Casting and mandatoriness are the whole of it; format,
|
|
311
|
+
ranges and permitted values belong to the application.
|
|
312
|
+
- **Not a secret store.** Rotation, delivery and permissions belong to the
|
|
313
|
+
infrastructure.
|
|
314
|
+
- **Not hot reload.** A configuration is read once per process.
|
|
315
|
+
- **Not a way to express structure through the source.** Nested structure lives in
|
|
316
|
+
the file; a value from the source replaces a scalar, or a flat list of scalars
|
|
317
|
+
given as one comma separated text.
|
|
318
|
+
- **Nothing about environments beyond the section.** Differences between
|
|
319
|
+
installations belong to variables and to per-environment files, never to logic
|
|
320
|
+
inside the configuration.
|
|
321
|
+
|
|
322
|
+
## 16. Conformance
|
|
323
|
+
|
|
324
|
+
An implementation conforms when it follows every MUST in sections 2 to 15.
|
|
325
|
+
|
|
326
|
+
Two implementations following this text alone MAY still disagree, because prose
|
|
327
|
+
cannot cover every case. Agreement between ports is established by a shared corpus
|
|
328
|
+
— an input, a source, and the tree or refusal that MUST result — not by a
|
|
329
|
+
document. Until one exists, the reference implementation is the tie-breaker.
|
|
330
|
+
|
|
331
|
+
The known edges of this format are listed in [decisions.md](decisions.md). They
|
|
332
|
+
are consequences of the rules above, not departures from them, and a conforming
|
|
333
|
+
implementation reproduces them.
|
data/kanon.gemspec
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/kanon/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'kanon'
|
|
7
|
+
spec.version = Kanon::VERSION
|
|
8
|
+
spec.authors = ['Maksimenko Pavel']
|
|
9
|
+
spec.email = ['pavel.g.maksimenko@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'A YAML config becomes a frozen tree of nodes, and declares the environment variables it needs'
|
|
12
|
+
spec.description = <<~TEXT
|
|
13
|
+
A YAML file becomes a frozen tree of nodes with dotted access, and the values
|
|
14
|
+
that belong to the environment are declared in that same file. A read that
|
|
15
|
+
misses a mandatory value fails naming every absent variable at once, and a
|
|
16
|
+
typo in a key raises instead of returning nil. ERB tags keep working, so a
|
|
17
|
+
file that already reads the environment that way needs no rewriting. The
|
|
18
|
+
standard library only, with the config directory and the environment name
|
|
19
|
+
injected from outside.
|
|
20
|
+
TEXT
|
|
21
|
+
|
|
22
|
+
spec.homepage = 'https://github.com/MaksimenkoPG/kanon'
|
|
23
|
+
spec.license = 'MIT'
|
|
24
|
+
spec.required_ruby_version = '>= 2.7'
|
|
25
|
+
|
|
26
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
27
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
28
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
|
|
29
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
30
|
+
|
|
31
|
+
spec.files = Dir.chdir(__dir__) do
|
|
32
|
+
`git ls-files -z`.split("\x0").reject do |path|
|
|
33
|
+
path.start_with?('spec/', 'script/', '.') || %w[Gemfile Rakefile].include?(path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
spec.require_paths = ['lib']
|
|
37
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kanon
|
|
4
|
+
class Document
|
|
5
|
+
KEYWORD_SAFE_LOAD = YAML.method(:safe_load).parameters.any? { |kind, name| kind == :key && name == :aliases }
|
|
6
|
+
|
|
7
|
+
def initialize(directory, file_name, environment)
|
|
8
|
+
@directory = directory
|
|
9
|
+
@file_name = file_name
|
|
10
|
+
@environment = environment
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def schema
|
|
14
|
+
narrowed = narrow_to_current_env(symbolized)
|
|
15
|
+
|
|
16
|
+
split_root(narrowed.nil? ? {} : narrowed)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def symbolized
|
|
22
|
+
deep_symbolize(parse(evaluate_erb(read_file)))
|
|
23
|
+
rescue SystemStackError
|
|
24
|
+
raise InvalidSource, "#{@file_name}.yml is nested too deeply to read or refers to itself through an alias",
|
|
25
|
+
cause: nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read_file
|
|
29
|
+
unless @file_name.to_s.match?(/\A[a-z0-9_]+\z/)
|
|
30
|
+
raise FileMissing,
|
|
31
|
+
"#{@file_name.inspect} is not a bare configuration file name"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
path = File.join(@directory, "#{@file_name}.yml")
|
|
35
|
+
raise FileMissing, "No such configuration file: #{path}" unless File.exist?(path)
|
|
36
|
+
|
|
37
|
+
File.read(path)
|
|
38
|
+
rescue SystemCallError => e
|
|
39
|
+
raise InvalidSource, "#{@file_name}.yml cannot be opened: #{e.class}", cause: nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Only the error class reaches the message: the original text carries the
|
|
43
|
+
# value of an environment variable into logs and exception mail.
|
|
44
|
+
def evaluate_erb(text)
|
|
45
|
+
ERB.new(text).result
|
|
46
|
+
rescue StandardError, ScriptError => e
|
|
47
|
+
raise InvalidSource, "#{@file_name}.yml failed while running ERB: #{e.class}", cause: nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse(text)
|
|
51
|
+
loaded = if KEYWORD_SAFE_LOAD
|
|
52
|
+
YAML.safe_load(text, permitted_classes: [Symbol], aliases: true)
|
|
53
|
+
else
|
|
54
|
+
YAML.safe_load(text, [Symbol], [], true)
|
|
55
|
+
end
|
|
56
|
+
loaded || {}
|
|
57
|
+
rescue Psych::SyntaxError => e
|
|
58
|
+
raise InvalidSource, "#{@file_name}.yml is not valid YAML: #{e.message}", cause: nil
|
|
59
|
+
rescue Psych::DisallowedClass => e
|
|
60
|
+
raise InvalidSource, "#{@file_name}.yml holds a YAML type the loader does not accept: #{e.message}",
|
|
61
|
+
cause: nil
|
|
62
|
+
rescue Psych::Exception => e
|
|
63
|
+
raise InvalidSource, "#{@file_name}.yml cannot be built into a document: #{e.message}", cause: nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def narrow_to_current_env(node)
|
|
67
|
+
return node unless node.is_a?(Hash) && node.key?(@environment.to_sym)
|
|
68
|
+
|
|
69
|
+
node.fetch(@environment.to_sym)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def split_root(node)
|
|
73
|
+
return [[@file_name], node] if node.is_a?(Array)
|
|
74
|
+
return [[], node] unless node.is_a?(Hash) && node.size == 1 && node.values.first.is_a?(Hash) &&
|
|
75
|
+
!Kanon::EnvDeclarations.declaration?(node.values.first)
|
|
76
|
+
|
|
77
|
+
[[node.keys.first], node.values.first]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def deep_symbolize(node)
|
|
81
|
+
case node
|
|
82
|
+
when Hash then symbolize_keys(node)
|
|
83
|
+
when Array then node.map { |value| deep_symbolize(value) }
|
|
84
|
+
else node
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def symbolize_keys(node)
|
|
89
|
+
seen = {}
|
|
90
|
+
node.each_with_object({}) do |(key, value), result|
|
|
91
|
+
name = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
92
|
+
reject_twin(seen[name], key) if seen.key?(name)
|
|
93
|
+
|
|
94
|
+
seen[name] = key
|
|
95
|
+
result[name] = deep_symbolize(value)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def reject_twin(first, second)
|
|
100
|
+
raise InvalidSource, "#{@file_name}.yml reads #{first.inspect} and #{second.inspect} as one key"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kanon
|
|
4
|
+
class EnvDeclarations
|
|
5
|
+
DECLARATION_KEYS = %i[env default type optional].freeze
|
|
6
|
+
NAME_SEPARATOR = '_'
|
|
7
|
+
USABLE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
8
|
+
|
|
9
|
+
private_constant :NAME_SEPARATOR, :USABLE_NAME
|
|
10
|
+
|
|
11
|
+
def self.declaration?(value)
|
|
12
|
+
return false unless value.is_a?(Hash)
|
|
13
|
+
return true if value.key?(:env)
|
|
14
|
+
|
|
15
|
+
value.keys.any? { |key| DECLARATION_KEYS.include?(key) } && (value.keys - DECLARATION_KEYS).empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(file_name, root_path = [])
|
|
19
|
+
@file_name = file_name
|
|
20
|
+
@root_path = root_path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def resolve(schema)
|
|
24
|
+
resolved = walk_with(schema, ENV)
|
|
25
|
+
raise MissingValue, "#{@file_name}.yml expects values from environment: #{@missing.join(', ')}" if @missing.any?
|
|
26
|
+
|
|
27
|
+
resolved
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def expected(schema)
|
|
31
|
+
walk_with(schema, {})
|
|
32
|
+
|
|
33
|
+
@taken.keys.to_h { |name| [name, @missing.include?(name)] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def variable_name(path)
|
|
37
|
+
usable((@root_path + path).map { |key| key.to_s.upcase }.join(NAME_SEPARATOR))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def walk_with(schema, source)
|
|
43
|
+
@source = source
|
|
44
|
+
@missing = []
|
|
45
|
+
@taken = {}
|
|
46
|
+
|
|
47
|
+
walk(schema, []) { |node, path| resolve_leaf(node, path) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def walk(node, path, &leaf)
|
|
51
|
+
case node
|
|
52
|
+
when Hash
|
|
53
|
+
return leaf.call(node, path) if self.class.declaration?(node)
|
|
54
|
+
|
|
55
|
+
node.each_with_object({}) { |(key, value), result| result[key] = walk(value, path + [key], &leaf) }
|
|
56
|
+
when Array then node.each_with_index.map { |value, index| walk(value, path + [index], &leaf) }
|
|
57
|
+
else leaf.call(node, path)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_leaf(node, path)
|
|
62
|
+
node.is_a?(Hash) ? resolve_declaration(node, path) : resolve_scalar(node, path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_declaration(declaration, path)
|
|
66
|
+
name = name_of(declaration, path)
|
|
67
|
+
reject_unknown_keys(declaration, name)
|
|
68
|
+
Typecast.reject_unknown_type(declaration[:type], name) if declaration.key?(:type)
|
|
69
|
+
claim(name, path)
|
|
70
|
+
|
|
71
|
+
value = value_of(name)
|
|
72
|
+
resolved = cast(value.nil? ? declaration[:default] : value, declaration, name)
|
|
73
|
+
return resolved unless resolved.nil?
|
|
74
|
+
|
|
75
|
+
@missing << name if mandatory?(declaration)
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# An empty value means "expected and not given", whatever produced the
|
|
80
|
+
# emptiness: a tilde in the file, an ERB tag or a missing variable.
|
|
81
|
+
def resolve_scalar(value, path)
|
|
82
|
+
return value unless value.nil?
|
|
83
|
+
|
|
84
|
+
name = variable_name(path)
|
|
85
|
+
claim(name, path)
|
|
86
|
+
resolved = value_of(name)
|
|
87
|
+
return resolved unless resolved.nil?
|
|
88
|
+
|
|
89
|
+
@missing << name
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def name_of(declaration, path)
|
|
94
|
+
declaration[:env] ? usable(declaration[:env].to_s) : variable_name(path)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def usable(name)
|
|
98
|
+
return name if USABLE_NAME.match?(name)
|
|
99
|
+
|
|
100
|
+
raise InvalidVariableName, "#{@file_name}.yml wants #{name.inspect}, which no shell can set as a variable"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def cast(value, declaration, name)
|
|
104
|
+
return nil if value.nil?
|
|
105
|
+
return Typecast.by_type_name(value, declaration[:type], name) if declaration.key?(:type)
|
|
106
|
+
|
|
107
|
+
Typecast.like_sample(value, declaration[:default], name)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The comparison is literal: the string 'false' from YAML is truthy in Ruby
|
|
111
|
+
# and would otherwise switch the flag on.
|
|
112
|
+
def mandatory?(declaration)
|
|
113
|
+
declaration[:optional] != true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def claim(name, path)
|
|
117
|
+
other = @taken[name]
|
|
118
|
+
if other
|
|
119
|
+
raise ConflictingVariable,
|
|
120
|
+
"#{@file_name}.yml reads #{name} for both #{other.join('.')} and #{path.join('.')}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@taken[name] = path
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def value_of(name)
|
|
127
|
+
raw = @source[name]
|
|
128
|
+
raw.nil? || raw.empty? ? nil : raw
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reject_unknown_keys(declaration, name)
|
|
132
|
+
unknown = declaration.keys - DECLARATION_KEYS
|
|
133
|
+
return if unknown.empty?
|
|
134
|
+
|
|
135
|
+
raise InvalidDeclaration,
|
|
136
|
+
"#{@file_name}.yml declares #{name} with unsupported keys #{unknown.inspect}, " \
|
|
137
|
+
"only #{DECLARATION_KEYS.inspect} are allowed"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
data/lib/kanon/errors.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kanon
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class FileMissing < Error; end
|
|
6
|
+
class InvalidSource < Error; end
|
|
7
|
+
class EmptyDirectory < Error; end
|
|
8
|
+
class EmptyEnvironment < Error; end
|
|
9
|
+
class InvalidDeclaration < Error; end
|
|
10
|
+
class InvalidVariableName < Error; end
|
|
11
|
+
class MissingValue < Error; end
|
|
12
|
+
class InvalidValue < Error; end
|
|
13
|
+
class ReservedKey < Error; end
|
|
14
|
+
class ConflictingVariable < Error; end
|
|
15
|
+
class UnknownKey < Error; end
|
|
16
|
+
end
|