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 }