1 /**
2  * Base utilities for working with configurations.
3  *
4  * Authors:
5  *  Mike Bierlee, m.bierlee@lostmoment.com
6  * Copyright: 2022 Mike Bierlee
7  * License:
8  *  This software is licensed under the terms of the MIT license.
9  *  The full terms of the license can be found in the LICENSE file.
10  */
11 
12 module mirage.config;
13 
14 import std.exception : enforce;
15 import std.string : split, startsWith, endsWith, join, lastIndexOf, strip, toLower;
16 import std.conv : to, ConvException;
17 import std.file : readText;
18 import std.path : extension;
19 
20 import mirage.json : loadJsonConfig;
21 
22 /** 
23  * Used by the ConfigDictionary when something goes wrong when reading configuration.
24  */
25 class ConfigReadException : Exception {
26     this(string msg, string file = __FILE__, size_t line = __LINE__) {
27         super(msg, file, line);
28     }
29 }
30 
31 /** 
32  * Used by ConfigFactory instances when loading or parsing configuration fails.
33  */
34 class ConfigCreationException : Exception {
35     this(string msg, string file = __FILE__, size_t line = __LINE__) {
36         super(msg, file, line);
37     }
38 }
39 
40 /** 
41  * Used by ConfigDictionary when there is something wrong with the path when calling ConfigDictionary.get()
42  */
43 class PathParseException : Exception {
44     this(string msg, string path, string file = __FILE__, size_t line = __LINE__) {
45         string fullMsg = msg ~ " (Path: " ~ path ~ ")";
46         super(fullMsg, file, line);
47     }
48 }
49 
50 /** 
51  * The configuration tree is made up of specific types of ConfigNodes.
52  * Used as generic type for ConfigFactory and ConfigDictionary.
53  */
54 interface ConfigNode {
55     string nodeType();
56 }
57 
58 /** 
59  * A configuration item that is any sort of primitive value (strings, numbers or null).
60  */
61 class ValueNode : ConfigNode {
62     string value;
63 
64     this() {
65     }
66 
67     this(string value) {
68         this.value = value;
69     }
70 
71     string nodeType() {
72         return "value";
73     }
74 }
75 
76 /** 
77  * A configuration item that is an object. 
78  * 
79  * ObjectNodes contain a node dictionary that points to other ConfigNodes.
80  */
81 class ObjectNode : ConfigNode {
82     ConfigNode[string] children;
83 
84     this() {
85     }
86 
87     this(ConfigNode[string] children) {
88         this.children = children;
89     }
90 
91     this(string[string] values) {
92         foreach (key, value; values) {
93             children[key] = new ValueNode(value);
94         }
95     }
96 
97     string nodeType() {
98         return "object";
99     }
100 }
101 
102 /** 
103  * A configuration item that is an array.
104  *
105  * Contains other ConfigNodes as children.
106  */
107 class ArrayNode : ConfigNode {
108     ConfigNode[] children;
109 
110     this() {
111     }
112 
113     this(ConfigNode[] children...) {
114         this.children = children;
115     }
116 
117     this(string[] values...) {
118         foreach (string value; values) {
119             children ~= new ValueNode(value);
120         }
121     }
122 
123     string nodeType() {
124         return "array";
125     }
126 }
127 
128 private interface PathSegment {
129 }
130 
131 private class ArrayPathSegment : PathSegment {
132     const size_t index;
133 
134     this(const size_t index) {
135         this.index = index;
136     }
137 }
138 
139 private class PropertyPathSegment : PathSegment {
140     const string propertyName;
141 
142     this(const string propertyName) {
143         this.propertyName = propertyName;
144     }
145 }
146 
147 private class ConfigPath {
148     private const string path;
149     private string[] previousSegments;
150     private string[] segments;
151 
152     this(const string path) {
153         this.path = path;
154         segmentAndNormalize(path);
155     }
156 
157     private void segmentAndNormalize(string path) {
158         foreach (segment; path.split(".")) {
159             auto trimmedSegment = segment.strip;
160 
161             if (trimmedSegment.length <= 0) {
162                 continue;
163             }
164 
165             if (trimmedSegment.endsWith("]") && !trimmedSegment.startsWith("[")) {
166                 auto openBracketPos = trimmedSegment.lastIndexOf("[");
167                 if (openBracketPos != -1) {
168                     segments ~= trimmedSegment[0 .. openBracketPos];
169                     segments ~= trimmedSegment[openBracketPos .. $];
170                     continue;
171                 }
172             }
173 
174             segments ~= trimmedSegment;
175         }
176     }
177 
178     PathSegment getNextSegment() {
179         if (segments.length == 0) {
180             return null;
181         }
182 
183         PathSegment ret(PathSegment segment) {
184             previousSegments ~= segments[0];
185             segments = segments[1 .. $];
186             return segment;
187         }
188 
189         string segment = segments[0];
190 
191         if (segment.startsWith("[") && segment.endsWith("]")) {
192             if (segment.length <= 2) {
193                 throw new PathParseException("Path has array accessor but no index specified", path);
194             }
195 
196             auto indexString = segment[1 .. $ - 1];
197             try {
198                 auto index = indexString.to!size_t;
199                 return ret(new ArrayPathSegment(index));
200             } catch (ConvException e) {
201                 throw new PathParseException("Value '" ~ indexString ~ "' is not acceptable as an array index", path);
202             }
203         }
204 
205         return ret(new PropertyPathSegment(segment));
206     }
207 
208     string getCurrentPath() {
209         return previousSegments.join(".");
210     }
211 }
212 
213 /** 
214  * A ConfigDictionary contains the configuration tree and facilities to get values from that tree.
215  */
216 class ConfigDictionary {
217     ConfigNode rootNode;
218 
219     this() {
220     }
221 
222     this(ConfigNode rootNode) {
223         this.rootNode = rootNode;
224     }
225 
226     /** 
227      * Get values from the configuration using config path notation.
228      *
229      * Params:
230      *   configPath = Path to the wanted config value. The path is separated by dots, e.g. "server.public.hostname". 
231      *                Values from arrays can be selected by brackets, for example: "server[3].hostname.ports[0]".
232      *                When the config is just a value, for example just a string, it can be fetched by just specifying "." as path.
233      *                Although the path should be universally the same over all types of config files, some might not lend to this structure,
234      *                and have a more specific way of retrieving data from the config. See the examples and specific config factories for
235      *                more details.
236      *
237      * Returns: The value at the path in the configuration. To convert it use get!T().
238      */
239     string get(string configPath) {
240         auto path = new ConfigPath(configPath);
241         auto node = getNodeAt(path);
242         auto value = cast(ValueNode) node;
243         if (value) {
244             return value.value;
245         } else {
246             throw new ConfigReadException(
247                 "Value expected but " ~ node.nodeType ~ " found at path: " ~ createExceptionPath(
248                     path));
249         }
250     }
251 
252     /** 
253      * Get values from the configuration and attempts to convert them to the specified type.
254      *
255      * Params:
256      *   configPath = Path to the wanted config value. See get(). 
257      * Returns: The value at the path in the configuration.
258      */
259     ConvertToType get(ConvertToType)(string configPath) {
260         return get(configPath).to!ConvertToType;
261     }
262 
263     /** 
264      * Fetch a sub-section of the config as another config.
265      * 
266      * Commonly used for example to fetch  further configuration from arrays, e.g.: `getConfig("http.servers[3]")` 
267      * which then returns the rest of the config at that path.
268      *
269      * Params:
270      *   configPath = Path to the wanted config. See get(). 
271      * Returns: A sub-section of the configuration.
272      */
273     ConfigDictionary getConfig(string configPath) {
274         auto path = new ConfigPath(configPath);
275         auto node = getNodeAt(path);
276         return new ConfigDictionary(node);
277     }
278 
279     string createExceptionPath(ConfigPath path) {
280         return "'" ~ path.path ~ "' (at '" ~ path.getCurrentPath() ~ "')";
281     }
282 
283     private ConfigNode getNodeAt(ConfigPath path) {
284         enforce!ConfigReadException(rootNode !is null, "The config is empty");
285 
286         auto currentNode = rootNode;
287         PathSegment currentPathSegment = path.getNextSegment();
288 
289         void throwPathNotExists() {
290             throw new ConfigReadException("Path does not exist: " ~ createExceptionPath(path));
291         }
292 
293         void ifNotNullPointer(void* obj, void delegate() fn) {
294             if (obj) {
295                 fn();
296             } else {
297                 throwPathNotExists();
298             }
299         }
300 
301         void ifNotNull(Object obj, void delegate() fn) {
302             if (obj) {
303                 fn();
304             } else {
305                 throwPathNotExists();
306             }
307         }
308 
309         while (currentPathSegment !is null) {
310             if (currentNode is null) {
311                 throwPathNotExists();
312             }
313 
314             auto valueNode = cast(ValueNode) currentNode;
315             if (valueNode) {
316                 throwPathNotExists();
317             }
318 
319             auto arrayPath = cast(ArrayPathSegment) currentPathSegment;
320             if (arrayPath) {
321                 auto arrayNode = cast(ArrayNode) currentNode;
322                 ifNotNull(arrayNode, {
323                     if (arrayNode.children.length < arrayPath.index) {
324                         throw new ConfigReadException(
325                             "Array index out of bounds: " ~ createExceptionPath(path));
326                     }
327 
328                     currentNode = arrayNode.children[arrayPath.index];
329                 });
330             }
331 
332             auto propertyPath = cast(PropertyPathSegment) currentPathSegment;
333             if (propertyPath) {
334                 auto objectNode = cast(ObjectNode) currentNode;
335                 ifNotNull(objectNode, {
336                     auto propertyNode = propertyPath.propertyName in objectNode.children;
337                     ifNotNullPointer(propertyNode, {
338                         currentNode = *propertyNode;
339                     });
340                 });
341             }
342 
343             currentPathSegment = path.getNextSegment();
344         }
345 
346         return currentNode;
347     }
348 }
349 
350 /** 
351  * The base class used by configuration factories for specific file types.
352  */
353 abstract class ConfigFactory {
354     /** 
355      * Loads a configuration from the specified path from disk.
356      *
357      * Params:
358      *   path = Path to file. OS dependent, but UNIX paths are generally working.
359      * Returns: The parsed configuration.
360      */
361     ConfigDictionary loadFile(string path) {
362         auto json = readText(path);
363         return parseConfig(json);
364     }
365 
366     /**
367      * Parse configuration from the given string.
368      *
369      * Params:
370      *   contents = Text contents of the config to be parsed.
371      * Returns: The parsed configuration.
372      */
373     ConfigDictionary parseConfig(string contents);
374 }
375 
376 ConfigDictionary loadConfig(const string configPath) {
377     auto extension = configPath.extension.toLower;
378     if (extension == ".json") {
379         return loadJsonConfig(configPath);
380     }
381 
382     throw new ConfigCreationException(
383         "File extension '" ~ extension ~ "' is not recognized as a supported config file format. Please use a specific function to load it, such as 'loadJsonConfig()'");
384 }
385 
386 version (unittest) {
387     import std.exception : assertThrown;
388     import std.math.operations : isClose;
389 
390     @("Dictionary creation")
391     unittest {
392         auto root = new ObjectNode([
393             "english": new ArrayNode([new ValueNode("one"), new ValueNode("two")]),
394             "spanish": new ArrayNode(new ValueNode("uno"), new ValueNode("dos"))
395         ]);
396 
397         auto config = new ConfigDictionary();
398         config.rootNode = root;
399     }
400 
401     @("Get value in config with empty root fails")
402     unittest {
403         auto config = new ConfigDictionary();
404 
405         assertThrown!ConfigReadException(config.get("."));
406     }
407 
408     @("Get value in root with empty path")
409     unittest {
410         auto config = new ConfigDictionary(new ValueNode("hehehe"));
411 
412         assert(config.get("") == "hehehe");
413     }
414 
415     @("Get value in root with just a dot")
416     unittest {
417         auto config = new ConfigDictionary(new ValueNode("yup"));
418 
419         assert(config.get(".") == "yup");
420     }
421 
422     @("Get value in root fails when root is not a value")
423     unittest {
424         auto config = new ConfigDictionary(new ArrayNode());
425 
426         assertThrown!ConfigReadException(config.get("."));
427     }
428 
429     @("Get array value from root")
430     unittest {
431         auto config = new ConfigDictionary(new ArrayNode("aap", "noot", "mies"));
432 
433         assert(config.get("[0]") == "aap");
434         assert(config.get("[1]") == "noot");
435         assert(config.get("[2]") == "mies");
436     }
437 
438     @("Get value from object at root")
439     unittest {
440         auto config = new ConfigDictionary(new ObjectNode([
441                 "aap": "monkey",
442                 "noot": "nut",
443                 "mies": "mies" // It's a name!
444             ])
445         );
446 
447         assert(config.get("aap") == "monkey");
448         assert(config.get("noot") == "nut");
449         assert(config.get("mies") == "mies");
450     }
451 
452     @("Get value from object in object")
453     unittest {
454         auto config = new ConfigDictionary(
455             new ObjectNode([
456                     "server": new ObjectNode([
457                         "port": "8080"
458                     ])
459                 ])
460         );
461 
462         assert(config.get("server.port") == "8080");
463     }
464 
465     @("Get value from array in object")
466     unittest {
467         auto config = new ConfigDictionary(
468             new ObjectNode([
469                 "hostname": new ArrayNode(["google.com", "dlang.org"])
470             ])
471         );
472 
473         assert(config.get("hostname.[1]") == "dlang.org");
474     }
475 
476     @("Exception is thrown when array out of bounds when fetching from root")
477     unittest {
478         auto config = new ConfigDictionary(
479             new ArrayNode([
480                     "google.com", "dlang.org"
481                 ])
482         );
483 
484         assertThrown!ConfigReadException(config.get("[5]"));
485     }
486 
487     @("Exception is thrown when array out of bounds when fetching from object")
488     unittest {
489         auto config = new ConfigDictionary(
490             new ObjectNode([
491                 "hostname": new ArrayNode(["google.com", "dlang.org"])
492             ])
493         );
494 
495         assertThrown!ConfigReadException(config.get("hostname.[5]"));
496     }
497 
498     @("Exception is thrown when path does not exist")
499     unittest {
500         auto config = new ConfigDictionary(new ObjectNode(
501                 [
502                     "hostname": new ObjectNode(["cluster": new ValueNode("")])
503                 ])
504         );
505 
506         assertThrown!ConfigReadException(config.get("hostname.cluster.spacey"));
507     }
508 
509     @("Exception is thrown when given path terminates too early")
510     unittest {
511         auto config = new ConfigDictionary(new ObjectNode(
512                 [
513                     "hostname": new ObjectNode(["cluster": new ValueNode(null)])
514                 ])
515         );
516 
517         assertThrown!ConfigReadException(config.get("hostname"));
518     }
519 
520     @("Exception is thrown when given path does not exist because config is an array")
521     unittest {
522         auto config = new ConfigDictionary(new ArrayNode());
523 
524         assertThrown!ConfigReadException(config.get("hostname"));
525     }
526 
527     @("Get value from objects in array")
528     unittest {
529         auto config = new ConfigDictionary(new ArrayNode(
530                 new ObjectNode(["wrong": "yes"]),
531                 new ObjectNode(["wrong": "no"]),
532                 new ObjectNode(["wrong": "very"]),
533         ));
534 
535         assert(config.get("[1].wrong") == "no");
536     }
537 
538     @("Get value from config with mixed types")
539     unittest {
540         auto config = new ConfigDictionary(
541             new ObjectNode([
542                 "uno": cast(ConfigNode) new ValueNode("one"),
543                 "dos": cast(ConfigNode) new ArrayNode(["nope", "two"]),
544                 "tres": cast(ConfigNode) new ObjectNode(["thisone": "three"])
545             ])
546         );
547 
548         assert(config.get("uno") == "one");
549         assert(config.get("dos.[1]") == "two");
550         assert(config.get("tres.thisone") == "three");
551     }
552 
553     @("Ignore empty segments")
554     unittest {
555         auto config = new ConfigDictionary(
556             new ObjectNode(
557                 [
558                 "one": new ObjectNode(["two": new ObjectNode(["three": "four"])])
559             ])
560         );
561 
562         assert(config.get(".one..two...three....") == "four");
563     }
564 
565     @("Support conventional array indexing notation")
566     unittest {
567         auto config = new ConfigDictionary(
568             new ObjectNode(
569                 [
570                     "one": new ObjectNode([
571                         "two": new ArrayNode(["dino", "mino"])
572                     ])
573                 ])
574         );
575 
576         assert(config.get("one.two[1]") == "mino");
577     }
578 
579     @("Get and convert values")
580     unittest {
581         auto config = new ConfigDictionary(
582             new ObjectNode([
583                 "uno": new ValueNode("1223"),
584                 "dos": new ValueNode("true"),
585                 "tres": new ValueNode("Hi you"),
586                 "quatro": new ValueNode("1.3")
587             ])
588         );
589 
590         assert(config.get!int("uno") == 1223);
591         assert(config.get!bool("dos") == true);
592         assert(config.get!string("tres") == "Hi you");
593         assert(isClose(config.get!float("quatro"), 1.3));
594     }
595 
596     @("Get config from array")
597     unittest {
598         auto configOne = new ConfigDictionary(new ObjectNode(
599                 [
600                 "servers": new ArrayNode([
601                     new ObjectNode(["hostname": "lala.com"]),
602                     new ObjectNode(["hostname": "lele.com"])
603                 ])
604             ])
605         );
606 
607         auto config = configOne.getConfig("servers[0]");
608         assert(config.get("hostname") == "lala.com");
609     }
610 
611     @("Trim spaces in path segments")
612     unittest {
613         auto config = new ConfigDictionary(
614             new ObjectNode(["que": new ObjectNode(["pasa hombre": "not much"])])
615         );
616 
617         assert(config.get("  que.    pasa hombre   ") == "not much");
618     }
619 
620     @("Load configurations using the loadConfig convenience function")
621     unittest {
622         auto jsonConfig = loadConfig("testfiles/groot.json");
623 
624         assert(jsonConfig.get("name") == "Groot");
625         assert(jsonConfig.get("traits[1]") == "tree");
626         assert(jsonConfig.get("age") == "8728");
627         assert(jsonConfig.get("taxNumber") == null);
628     }
629 }