1 /** 2 * Base utilities for working with configurations. 3 * 4 * Authors: 5 * Mike Bierlee, m.bierlee@lostmoment.com 6 * Copyright: 2022-2025 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 import std.process : environment; 20 import std.typecons : Flag; 21 22 import mirage.json : loadJsonConfig; 23 import mirage.java : loadJavaProperties; 24 import mirage.ini : loadIniConfig; 25 26 /** 27 * Used by the ConfigDictionary when something goes wrong when reading configuration. 28 */ 29 class ConfigReadException : Exception { 30 this(string msg, string file = __FILE__, size_t line = __LINE__) { 31 super(msg, file, line); 32 } 33 } 34 35 /** 36 * Used by ConfigDictionary when the supplied path does not exist. 37 */ 38 class ConfigPathNotFoundException : Exception { 39 this(string msg, string file = __FILE__, size_t line = __LINE__) { 40 super(msg, file, line); 41 } 42 } 43 44 /** 45 * Used by ConfigFactory instances when loading or parsing configuration fails. 46 */ 47 class ConfigCreationException : Exception { 48 this(string msg, string file = __FILE__, size_t line = __LINE__) { 49 super(msg, file, line); 50 } 51 } 52 53 /** 54 * Used by ConfigDictionary when there is something wrong with the path when calling ConfigDictionary.get() 55 */ 56 class PathParseException : Exception { 57 this(string msg, string path, string file = __FILE__, size_t line = __LINE__) { 58 string fullMsg = msg ~ " (Path: " ~ path ~ ")"; 59 super(fullMsg, file, line); 60 } 61 } 62 63 /** 64 * The configuration tree is made up of specific types of ConfigNodes. 65 * Used as generic type for ConfigFactory and ConfigDictionary. 66 */ 67 interface ConfigNode { 68 string nodeType(); 69 } 70 71 /** 72 * A configuration item that is any sort of primitive value (strings, numbers or null). 73 */ 74 class ValueNode : ConfigNode { 75 string value; 76 77 this() { 78 } 79 80 this(string value) { 81 this.value = value; 82 } 83 84 string nodeType() { 85 return "value"; 86 } 87 } 88 89 /** 90 * A configuration item that is an object. 91 * 92 * ObjectNodes contain a node dictionary that points to other ConfigNodes. 93 */ 94 class ObjectNode : ConfigNode { 95 ConfigNode[string] children; 96 97 this() { 98 } 99 100 this(ConfigNode[string] children) { 101 this.children = children; 102 } 103 104 this(string[string] values) { 105 foreach (key, value; values) { 106 children[key] = new ValueNode(value); 107 } 108 } 109 110 string nodeType() { 111 return "object"; 112 } 113 } 114 115 /** 116 * A configuration item that is an array. 117 * 118 * Contains other ConfigNodes as children. 119 */ 120 class ArrayNode : ConfigNode { 121 ConfigNode[] children; 122 123 this() { 124 } 125 126 this(ConfigNode[] children...) { 127 this.children = children; 128 } 129 130 this(string[] values...) { 131 foreach (string value; values) { 132 children ~= new ValueNode(value); 133 } 134 } 135 136 string nodeType() { 137 return "array"; 138 } 139 } 140 141 private interface PathSegment { 142 } 143 144 private class ArrayPathSegment : PathSegment { 145 const size_t index; 146 147 this(const size_t index) { 148 this.index = index; 149 } 150 } 151 152 private class PropertyPathSegment : PathSegment { 153 const string propertyName; 154 155 this(const string propertyName) { 156 this.propertyName = propertyName; 157 } 158 } 159 160 private class ConfigPath { 161 private const string path; 162 private string[] previousSegments; 163 private string[] segments; 164 165 this(const string path) { 166 this.path = path; 167 segmentAndNormalize(path); 168 } 169 170 private void segmentAndNormalize(string path) { 171 foreach (segment; path.split(".")) { 172 auto trimmedSegment = segment.strip; 173 174 if (trimmedSegment.length <= 0) { 175 continue; 176 } 177 178 if (trimmedSegment.endsWith("]") && !trimmedSegment.startsWith("[")) { 179 auto openBracketPos = trimmedSegment.lastIndexOf("["); 180 if (openBracketPos != -1) { 181 segments ~= trimmedSegment[0 .. openBracketPos]; 182 segments ~= trimmedSegment[openBracketPos .. $]; 183 continue; 184 } 185 } 186 187 segments ~= trimmedSegment; 188 } 189 } 190 191 PathSegment getNextSegment() { 192 if (segments.length == 0) { 193 return null; 194 } 195 196 PathSegment ret(PathSegment segment) { 197 previousSegments ~= segments[0]; 198 segments = segments[1 .. $]; 199 return segment; 200 } 201 202 string segment = segments[0]; 203 204 if (segment.startsWith("[") && segment.endsWith("]")) { 205 if (segment.length <= 2) { 206 throw new PathParseException("Path has array accessor but no index specified", path); 207 } 208 209 auto indexString = segment[1 .. $ - 1]; 210 try { 211 auto index = indexString.to!size_t; 212 return ret(new ArrayPathSegment(index)); 213 } catch (ConvException e) { 214 throw new PathParseException("Value '" ~ indexString ~ "' is not acceptable as an array index", path); 215 } 216 } 217 218 return ret(new PropertyPathSegment(segment)); 219 } 220 221 string getCurrentPath() { 222 return previousSegments.join("."); 223 } 224 } 225 226 /** 227 * Used in a ConfigDictionary to enable to disable environment variable substitution. 228 */ 229 alias SubstituteEnvironmentVariables = Flag!"SubstituteEnvironmentVariables"; 230 231 /** 232 * Used in a ConfigDictionary to enable to disable config path substitution. 233 */ 234 alias SubstituteConfigVariables = Flag!"SubstituteConfigVariables"; 235 236 /** 237 * A ConfigDictionary contains the configuration tree and facilities to get values from that tree. 238 */ 239 class ConfigDictionary { 240 ConfigNode rootNode; 241 SubstituteEnvironmentVariables substituteEnvironmentVariables = SubstituteEnvironmentVariables 242 .yes; 243 SubstituteConfigVariables substituteConfigVariables = SubstituteConfigVariables.yes; 244 245 this(SubstituteEnvironmentVariables substituteEnvironmentVariables = SubstituteEnvironmentVariables 246 .yes, SubstituteConfigVariables substituteConfigVariables = SubstituteConfigVariables 247 .yes) { 248 this.substituteEnvironmentVariables = substituteEnvironmentVariables; 249 this.substituteConfigVariables = substituteConfigVariables; 250 } 251 252 this(ConfigNode rootNode, SubstituteEnvironmentVariables substituteEnvironmentVariables = SubstituteEnvironmentVariables 253 .yes, SubstituteConfigVariables substituteConfigVariables = SubstituteConfigVariables 254 .yes) { 255 this(substituteEnvironmentVariables, substituteConfigVariables); 256 this.rootNode = rootNode; 257 } 258 259 /** 260 * Get values from the configuration using config path notation. 261 * 262 * Params: 263 * configPath = Path to the wanted config value. The path is separated by dots, e.g. "server.public.hostname". 264 * Values from arrays can be selected by brackets, for example: "server[3].hostname.ports[0]". 265 * When the config is just a value, for example just a string, it can be fetched by just specifying "." as path. 266 * Although the path should be universally the same over all types of config files, some might not lend to this structure, 267 * and have a more specific way of retrieving data from the config. See the examples and specific config factories for 268 * more details. 269 * defaultValue = (Optional) Value to return when the given configPath is invalid. When not supplied a ConfigPathNotFoundException exception is thrown. 270 * 271 * Throws: ConfigReadException when something goes wrong reading the config. 272 * ConfigPathNotFoundException when the given path does not exist in the config. 273 * 274 * Returns: The value at the path in the configuration. To convert it use get!T(). 275 */ 276 string get(string configPath, string defaultValue = null) { 277 try { 278 auto path = new ConfigPath(configPath); 279 auto node = getNodeAt(path); 280 auto value = cast(ValueNode) node; 281 if (value) { 282 if (substituteEnvironmentVariables || substituteConfigVariables) { 283 return substituteVariables(value); 284 } else { 285 return value.value; 286 } 287 } else { 288 throw new ConfigReadException( 289 "Value expected but " ~ node.nodeType ~ " found at path: " ~ createExceptionPath( 290 path)); 291 } 292 } catch (ConfigPathNotFoundException e) { 293 if (defaultValue !is null) { 294 return defaultValue; 295 } 296 297 throw e; 298 } 299 } 300 301 /** 302 * Get values from the configuration and attempts to convert them to the specified type. 303 * 304 * Params: 305 * configPath = Path to the wanted config value. See get(). 306 * 307 * Throws: ConfigReadException when something goes wrong reading the config. 308 * ConfigPathNotFoundException when the given path does not exist in the config. 309 * 310 * Returns: The value at the path in the configuration. 311 * See_Also: get 312 */ 313 ConvertToType get(ConvertToType)(string configPath) { 314 return get(configPath).to!ConvertToType; 315 } 316 317 /** 318 * Get values from the configuration and attempts to convert them to the specified type. 319 * 320 * Params: 321 * configPath = Path to the wanted config value. See get(). 322 * defaultValue = (Optional) Value to return when the given configPath is invalid. When not supplied a ConfigPathNotFoundException exception is thrown. 323 * 324 * Throws: ConfigReadException when something goes wrong reading the config. 325 * ConfigPathNotFoundException when the given path does not exist in the config. 326 * 327 * Returns: The value at the path in the configuration. 328 * See_Also: get 329 */ 330 ConvertToType get(ConvertToType)(string configPath, ConvertToType defaultValue) { 331 try { 332 return get(configPath).to!ConvertToType; 333 } catch (ConfigPathNotFoundException e) { 334 return defaultValue; 335 } 336 } 337 338 /** 339 * Fetch a sub-section of the config as another config. 340 * 341 * Commonly used for example to fetch further configuration from arrays, e.g.: `getConfig("http.servers[3]")` 342 * which then returns the rest of the config at that path. 343 * 344 * Params: 345 * configPath = Path to the wanted config. See get(). 346 * Returns: A sub-section of the configuration. 347 */ 348 ConfigDictionary getConfig(string configPath) { 349 auto path = new ConfigPath(configPath); 350 auto node = getNodeAt(path); 351 return new ConfigDictionary(node); 352 } 353 354 /** 355 * Assign a value at the given path. 356 * 357 * Params: 358 * configPath = Path where to assign the value to. If the path does not exist, it will be created. 359 * value = Value to set at path. 360 */ 361 void set(string configPath, string value) { 362 auto path = new ConfigPath(configPath); 363 rootNode = insertAtPath(rootNode, path, value); 364 } 365 366 private string createExceptionPath(ConfigPath path) { 367 return "'" ~ path.path ~ "' (at '" ~ path.getCurrentPath() ~ "')"; 368 } 369 370 private ConfigNode getNodeAt(ConfigPath path) { 371 void throwPathNotFound() { 372 throw new ConfigPathNotFoundException( 373 "Path does not exist: " ~ createExceptionPath(path)); 374 } 375 376 if (rootNode is null) { 377 throwPathNotFound(); 378 } 379 380 auto currentNode = rootNode; 381 PathSegment currentPathSegment = path.getNextSegment(); 382 383 void ifNotNullPointer(void* obj, void delegate() fn) { 384 if (obj) { 385 fn(); 386 } else { 387 throwPathNotFound(); 388 } 389 } 390 391 void ifNotNull(Object obj, void delegate() fn) { 392 if (obj) { 393 fn(); 394 } else { 395 throwPathNotFound(); 396 } 397 } 398 399 while (currentPathSegment !is null) { 400 if (currentNode is null) { 401 throwPathNotFound(); 402 } 403 404 auto valueNode = cast(ValueNode) currentNode; 405 if (valueNode) { 406 throwPathNotFound(); 407 } 408 409 auto arrayPath = cast(ArrayPathSegment) currentPathSegment; 410 if (arrayPath) { 411 auto arrayNode = cast(ArrayNode) currentNode; 412 ifNotNull(arrayNode, { 413 if (arrayNode.children.length < arrayPath.index) { 414 throw new ConfigReadException( 415 "Array index out of bounds: " ~ createExceptionPath(path)); 416 } 417 418 currentNode = arrayNode.children[arrayPath.index]; 419 }); 420 } 421 422 auto propertyPath = cast(PropertyPathSegment) currentPathSegment; 423 if (propertyPath) { 424 auto objectNode = cast(ObjectNode) currentNode; 425 ifNotNull(objectNode, { 426 auto propertyNode = propertyPath.propertyName in objectNode.children; 427 ifNotNullPointer(propertyNode, { 428 currentNode = *propertyNode; 429 }); 430 }); 431 } 432 433 currentPathSegment = path.getNextSegment(); 434 } 435 436 return currentNode; 437 } 438 439 private string substituteVariables(ValueNode valueNode) { 440 auto value = valueNode.value; 441 if (value == null) { 442 return value; 443 } 444 445 auto result = ""; 446 auto isParsingEnvVar = false; 447 auto isParsingDefault = false; 448 auto escapeNextCharacter = false; 449 auto varName = ""; 450 auto defaultVarValue = ""; 451 452 void addVarValueToResult() { 453 varName = varName.strip; 454 455 if (varName.length == 0) { 456 return; 457 } 458 459 string[] exceptionMessageParts; 460 461 if (substituteEnvironmentVariables) { 462 exceptionMessageParts ~= "environment variable"; 463 auto envVarValue = environment.get(varName); 464 if (envVarValue !is null) { 465 result ~= envVarValue; 466 return; 467 } 468 } 469 470 if (substituteConfigVariables) { 471 exceptionMessageParts ~= "config path"; 472 auto configValue = get(varName, defaultVarValue); 473 if (configValue.length > 0) { 474 result ~= configValue; 475 return; 476 } 477 } 478 479 if (isParsingDefault) { 480 result ~= defaultVarValue.strip; 481 return; 482 } 483 484 auto exceptionMessageComponents = exceptionMessageParts.join(" or "); 485 throw new ConfigReadException( 486 "No substitution found for " ~ exceptionMessageComponents ~ ": " ~ varName); 487 } 488 489 foreach (size_t i, char c; value) { 490 if (escapeNextCharacter) { 491 result ~= c; 492 escapeNextCharacter = false; 493 continue; 494 } 495 496 if (c == '\\' && value.length.to!int - 1 > i && value[i + 1] == '$') { 497 escapeNextCharacter = true; 498 continue; 499 } 500 501 if (c == '$') { 502 isParsingEnvVar = true; 503 continue; 504 } 505 506 if (isParsingEnvVar) { 507 if (c == '{') { 508 continue; 509 } 510 511 if (c == '}') { 512 addVarValueToResult(); 513 isParsingEnvVar = false; 514 isParsingDefault = false; 515 varName = ""; 516 defaultVarValue = ""; 517 continue; 518 } 519 520 if (isParsingDefault) { 521 defaultVarValue ~= c; 522 continue; 523 } 524 525 if (c == ':') { 526 isParsingDefault = true; 527 continue; 528 } 529 530 varName ~= c; 531 continue; 532 } 533 534 result ~= c; 535 } 536 537 if (isParsingEnvVar) { 538 if (varName.length > 0) { 539 addVarValueToResult(); 540 } else { 541 result ~= '$'; 542 } 543 } 544 545 return result; 546 } 547 548 private ConfigNode insertAtPath(ConfigNode node, ConfigPath path, const string value) { 549 auto nextSegment = path.getNextSegment(); 550 if (nextSegment is null) { 551 return new ValueNode(value); 552 } 553 554 auto propertySegment = cast(PropertyPathSegment) nextSegment; 555 if (propertySegment is null) { 556 throw new Exception("Not yet implemented: cannot set array"); // todo 557 } 558 559 auto objectNode = node is null ? new ObjectNode() : cast(ObjectNode) node; 560 if (objectNode is null) { 561 throw new Exception("Not yet implemented: cannot set array"); // todo 562 } 563 564 auto childNodePtr = propertySegment.propertyName in objectNode.children; 565 ConfigNode childNode = childNodePtr !is null ? *childNodePtr : new ObjectNode(); 566 objectNode.children[propertySegment.propertyName] = insertAtPath(childNode, path, value); 567 return objectNode; 568 } 569 } 570 571 /** 572 * The base class used by configuration factories for specific file types. 573 */ 574 abstract class ConfigFactory { 575 /** 576 * Loads a configuration from the specified path from disk. 577 * 578 * Params: 579 * path = Path to file. OS dependent, but UNIX paths are generally working. 580 * Returns: The parsed configuration. 581 */ 582 ConfigDictionary loadFile(string path) { 583 auto json = readText(path); 584 return parseConfig(json); 585 } 586 587 /** 588 * Parse configuration from the given string. 589 * 590 * Params: 591 * contents = Text contents of the config to be parsed. 592 * Returns: The parsed configuration. 593 */ 594 ConfigDictionary parseConfig(string contents); 595 } 596 597 /** 598 * Load config from disk. 599 * A specific loader will be used based on the file's extension. 600 * Params: 601 * configPath = Path to the configuration file. 602 * Returns: The loaded configuration. 603 * Throws: ConfigCreationException when the file's extension is unrecognized. 604 */ 605 ConfigDictionary loadConfig(const string configPath) { 606 auto extension = configPath.extension.toLower; 607 if (extension == ".json") { 608 return loadJsonConfig(configPath); 609 } 610 611 if (extension == ".properties") { 612 return loadJavaProperties(configPath); 613 } 614 615 if (extension == ".ini") { 616 return loadIniConfig(configPath); 617 } 618 619 throw new ConfigCreationException( 620 "File extension '" ~ extension ~ "' is not recognized as a supported config file format. Please use a specific function to load it, such as 'loadJsonConfig()'"); 621 } 622 623 version (unittest) { 624 import std.exception : assertThrown; 625 import std.math.operations : isClose; 626 627 @("Dictionary creation") 628 unittest { 629 auto root = new ObjectNode([ 630 "english": new ArrayNode([new ValueNode("one"), new ValueNode("two")]), 631 "spanish": new ArrayNode(new ValueNode("uno"), new ValueNode("dos")) 632 ]); 633 634 auto config = new ConfigDictionary(); 635 config.rootNode = root; 636 } 637 638 @("Get value in config with empty root fails") 639 unittest { 640 auto config = new ConfigDictionary(); 641 642 assertThrown!ConfigPathNotFoundException(config.get(".")); 643 } 644 645 @("Get value in root with empty path") 646 unittest { 647 auto config = new ConfigDictionary(new ValueNode("hehehe")); 648 649 assert(config.get("") == "hehehe"); 650 } 651 652 @("Get value in root with just a dot") 653 unittest { 654 auto config = new ConfigDictionary(new ValueNode("yup")); 655 656 assert(config.get(".") == "yup"); 657 } 658 659 @("Get value in root fails when root is not a value") 660 unittest { 661 auto config = new ConfigDictionary(new ArrayNode()); 662 663 assertThrown!ConfigReadException(config.get(".")); 664 } 665 666 @("Get array value from root") 667 unittest { 668 auto config = new ConfigDictionary(new ArrayNode("aap", "noot", "mies")); 669 670 assert(config.get("[0]") == "aap"); 671 assert(config.get("[1]") == "noot"); 672 assert(config.get("[2]") == "mies"); 673 } 674 675 @("Get value from object at root") 676 unittest { 677 auto config = new ConfigDictionary(new ObjectNode([ 678 "aap": "monkey", 679 "noot": "nut", 680 "mies": "mies" // It's a name! 681 ]) 682 ); 683 684 assert(config.get("aap") == "monkey"); 685 assert(config.get("noot") == "nut"); 686 assert(config.get("mies") == "mies"); 687 } 688 689 @("Get value from object in object") 690 unittest { 691 auto config = new ConfigDictionary( 692 new ObjectNode([ 693 "server": new ObjectNode([ 694 "port": "8080" 695 ]) 696 ]) 697 ); 698 699 assert(config.get("server.port") == "8080"); 700 } 701 702 @("Get value from array in object") 703 unittest { 704 auto config = new ConfigDictionary( 705 new ObjectNode([ 706 "hostname": new ArrayNode(["google.com", "dlang.org"]) 707 ]) 708 ); 709 710 assert(config.get("hostname.[1]") == "dlang.org"); 711 } 712 713 @("Exception is thrown when array out of bounds when fetching from root") 714 unittest { 715 auto config = new ConfigDictionary( 716 new ArrayNode([ 717 "google.com", "dlang.org" 718 ]) 719 ); 720 721 assertThrown!ConfigReadException(config.get("[5]")); 722 } 723 724 @("Exception is thrown when array out of bounds when fetching from object") 725 unittest { 726 auto config = new ConfigDictionary( 727 new ObjectNode([ 728 "hostname": new ArrayNode(["google.com", "dlang.org"]) 729 ]) 730 ); 731 732 assertThrown!ConfigReadException(config.get("hostname.[5]")); 733 } 734 735 @("Exception is thrown when path does not exist") 736 unittest { 737 auto config = new ConfigDictionary(new ObjectNode( 738 [ 739 "hostname": new ObjectNode(["cluster": new ValueNode("")]) 740 ]) 741 ); 742 743 assertThrown!ConfigPathNotFoundException(config.get("hostname.cluster.spacey")); 744 } 745 746 @("Exception is thrown when given path terminates too early") 747 unittest { 748 auto config = new ConfigDictionary(new ObjectNode( 749 [ 750 "hostname": new ObjectNode(["cluster": new ValueNode(null)]) 751 ]) 752 ); 753 754 assertThrown!ConfigReadException(config.get("hostname")); 755 } 756 757 @("Exception is thrown when given path does not exist because config is an array") 758 unittest { 759 auto config = new ConfigDictionary(new ArrayNode()); 760 761 assertThrown!ConfigPathNotFoundException(config.get("hostname")); 762 } 763 764 @("Get value from objects in array") 765 unittest { 766 auto config = new ConfigDictionary(new ArrayNode( 767 new ObjectNode(["wrong": "yes"]), 768 new ObjectNode(["wrong": "no"]), 769 new ObjectNode(["wrong": "very"]), 770 )); 771 772 assert(config.get("[1].wrong") == "no"); 773 } 774 775 @("Get value from config with mixed types") 776 unittest { 777 auto config = new ConfigDictionary( 778 new ObjectNode([ 779 "uno": cast(ConfigNode) new ValueNode("one"), 780 "dos": cast(ConfigNode) new ArrayNode(["nope", "two"]), 781 "tres": cast(ConfigNode) new ObjectNode(["thisone": "three"]) 782 ]) 783 ); 784 785 assert(config.get("uno") == "one"); 786 assert(config.get("dos.[1]") == "two"); 787 assert(config.get("tres.thisone") == "three"); 788 } 789 790 @("Ignore empty segments") 791 unittest { 792 auto config = new ConfigDictionary( 793 new ObjectNode( 794 [ 795 "one": new ObjectNode(["two": new ObjectNode(["three": "four"])]) 796 ]) 797 ); 798 799 assert(config.get(".one..two...three....") == "four"); 800 } 801 802 @("Support conventional array indexing notation") 803 unittest { 804 auto config = new ConfigDictionary( 805 new ObjectNode( 806 [ 807 "one": new ObjectNode([ 808 "two": new ArrayNode(["dino", "mino"]) 809 ]) 810 ]) 811 ); 812 813 assert(config.get("one.two[1]") == "mino"); 814 } 815 816 @("Get and convert values") 817 unittest { 818 auto config = new ConfigDictionary( 819 new ObjectNode([ 820 "uno": new ValueNode("1223"), 821 "dos": new ValueNode("true"), 822 "tres": new ValueNode("Hi you"), 823 "quatro": new ValueNode("1.3") 824 ]) 825 ); 826 827 assert(config.get!int("uno") == 1223); 828 assert(config.get!bool("dos") == true); 829 assert(config.get!string("tres") == "Hi you"); 830 assert(isClose(config.get!float("quatro"), 1.3)); 831 } 832 833 @("Get config from array") 834 unittest { 835 auto configOne = new ConfigDictionary(new ObjectNode( 836 [ 837 "servers": new ArrayNode([ 838 new ObjectNode(["hostname": "lala.com"]), 839 new ObjectNode(["hostname": "lele.com"]) 840 ]) 841 ]) 842 ); 843 844 auto config = configOne.getConfig("servers[0]"); 845 assert(config.get("hostname") == "lala.com"); 846 } 847 848 @("Trim spaces in path segments") 849 unittest { 850 auto config = new ConfigDictionary( 851 new ObjectNode(["que": new ObjectNode(["pasa hombre": "not much"])]) 852 ); 853 854 assert(config.get(" que. pasa hombre ") == "not much"); 855 } 856 857 @("Load configurations using the loadConfig convenience function") 858 unittest { 859 auto jsonConfig = loadConfig("testfiles/groot.json"); 860 assert(jsonConfig.get("name") == "Groot"); 861 assert(jsonConfig.get("traits[1]") == "tree"); 862 assert(jsonConfig.get("age") == "8728"); 863 assert(jsonConfig.get("taxNumber") == null); 864 865 auto javaProperties = loadConfig("testfiles/groot.properties"); 866 assert(javaProperties.get("name") == "Groot"); 867 assert(javaProperties.get("age") == "8728"); 868 assert(javaProperties.get("taxNumber") == "null"); 869 870 auto iniConfig = loadConfig("testfiles/groot.ini"); 871 assert(iniConfig.get("groot.name") == "Groot"); 872 assert(iniConfig.get("groot.age") == "8728"); 873 assert(iniConfig.get("groot.taxNumber") == "null"); 874 } 875 876 @("Whitespace is preserved in values") 877 unittest { 878 auto config = new ConfigDictionary(new ObjectNode([ 879 "bla": " blergh " 880 ])); 881 882 assert(config.get("bla") == " blergh "); 883 } 884 885 @("Null value stays null, not string") 886 unittest { 887 auto config = new ConfigDictionary(new ValueNode(null)); 888 assert(config.get(".") == null); 889 } 890 891 @("Read value from environment variable") 892 unittest { 893 environment["MIRAGE_CONFIG_TEST_ENV_VAR"] = "is set!"; 894 environment["MIRAGE_CONFIG_TEST_ENV_VAR_TWO"] = "is ready!"; 895 896 auto config = new ConfigDictionary( 897 new ObjectNode( 898 [ 899 "withBrackets": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR}"), 900 "withoutBrackets": new ValueNode("$MIRAGE_CONFIG_TEST_ENV_VAR"), 901 "withWhiteSpace": new ValueNode(" ${MIRAGE_CONFIG_TEST_ENV_VAR} "), 902 "alsoWithWhiteSpace": new ValueNode(" $MIRAGE_CONFIG_TEST_ENV_VAR"), 903 "whitespaceAtEnd": new ValueNode("$MIRAGE_CONFIG_TEST_ENV_VAR "), 904 "notSet": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR}"), 905 "alsoNotSet": new ValueNode("$MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR"), 906 "withDefault": new ValueNode("$MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!"), 907 "withDefaultAndBrackets": new ValueNode( 908 "${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!}"), 909 "megaMix": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR_TWO} ${MIRAGE_CONFIG_TEST_ENV_VAR} ${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:go}!"), 910 "typical": new ValueNode("${MIRAGE_CONFIG_TEST_HOSTNAME:localhost}:${MIRAGE_CONFIG_TEST_PORT:8080}"), 911 "whitespaceInVar": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR }"), 912 "whitespaceInDefault": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR: bruh}"), 913 "emptyDefault": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:}"), 914 "emptyDefaultWhiteSpace": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR: }"), 915 ]), 916 SubstituteEnvironmentVariables.yes, 917 SubstituteConfigVariables.no 918 ); 919 920 assert(config.get("withBrackets") == "is set!"); 921 assert(config.get("withoutBrackets") == "is set!"); 922 assert(config.get("withWhiteSpace") == " is set! "); 923 assert(config.get("alsoWithWhiteSpace") == " is set!"); 924 assert(config.get("whitespaceAtEnd") == "is set!"); 925 assertThrown!ConfigReadException(config.get("notSet")); // Environment variable not found 926 assertThrown!ConfigReadException(config.get("alsoNotSet")); // Environment variable not found 927 assert(config.get("withDefault") == "use default!"); 928 assert(config.get("withDefaultAndBrackets") == "use default!"); 929 assert(config.get("megaMix") == "is ready! is set! go!"); 930 assert(config.get("typical") == "localhost:8080"); 931 assert(config.get("whitespaceInVar") == "is set!"); 932 assert(config.get("whitespaceInDefault") == "bruh"); 933 assert(config.get("emptyDefault") == ""); 934 assert(config.get("emptyDefaultWhiteSpace") == ""); 935 } 936 937 @("Don't read value from environment variables when disabled") 938 unittest { 939 environment.remove("MIRAGE_CONFIG_TEST_ENV_VAR"); 940 environment.remove("MIRAGE_CONFIG_TEST_ENV_VAR_TWO"); 941 942 auto config = new ConfigDictionary( 943 new ObjectNode( 944 [ 945 "withBrackets": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR}"), 946 "withoutBrackets": new ValueNode("$MIRAGE_CONFIG_TEST_ENV_VAR"), 947 "withWhiteSpace": new ValueNode(" ${MIRAGE_CONFIG_TEST_ENV_VAR} "), 948 "alsoWithWhiteSpace": new ValueNode(" $MIRAGE_CONFIG_TEST_ENV_VAR"), 949 "tooMuchWhiteSpace": new ValueNode("$MIRAGE_CONFIG_TEST_ENV_VAR "), 950 "notSet": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR}"), 951 "withDefault": new ValueNode("$MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!"), 952 "withDefaultAndBrackets": new ValueNode( 953 "${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!}"), 954 "megaMix": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR_TWO} ${MIRAGE_CONFIG_TEST_ENV_VAR} ${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:go}!"), 955 "typical": new ValueNode("${MIRAGE_CONFIG_TEST_HOSTNAME:localhost}:${MIRAGE_CONFIG_TEST_PORT:8080}"), 956 ]), 957 SubstituteEnvironmentVariables.no, 958 SubstituteConfigVariables.no 959 ); 960 961 assert(config.get("withBrackets") == "${MIRAGE_CONFIG_TEST_ENV_VAR}"); 962 assert(config.get("withoutBrackets") == "$MIRAGE_CONFIG_TEST_ENV_VAR"); 963 assert(config.get("withWhiteSpace") == " ${MIRAGE_CONFIG_TEST_ENV_VAR} "); 964 assert(config.get("alsoWithWhiteSpace") == " $MIRAGE_CONFIG_TEST_ENV_VAR"); 965 assert(config.get("tooMuchWhiteSpace") == "$MIRAGE_CONFIG_TEST_ENV_VAR "); 966 assert(config.get("notSet") == "${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR}"); 967 assert(config.get("withDefault") == "$MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!"); 968 assert(config.get( 969 "withDefaultAndBrackets") == "${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!}"); 970 assert(config.get("megaMix") == "${MIRAGE_CONFIG_TEST_ENV_VAR_TWO} ${MIRAGE_CONFIG_TEST_ENV_VAR} ${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:go}!"); 971 assert(config.get( 972 "typical") == "${MIRAGE_CONFIG_TEST_HOSTNAME:localhost}:${MIRAGE_CONFIG_TEST_PORT:8080}"); 973 } 974 975 @("Get with default should return default") 976 unittest { 977 auto config = new ConfigDictionary(); 978 assert(config.get("la.la.la", "not there") == "not there"); 979 assert(config.get!int("do.re.mi.fa.so", 42) == 42); 980 } 981 982 @("Substitute values from other config paths") 983 unittest { 984 auto config = new ConfigDictionary( 985 new ObjectNode([ 986 "greet": cast(ConfigNode) new ObjectNode([ 987 "env": "Hi" 988 ]), 989 "hi": cast(ConfigNode) new ValueNode("${greet.env} there!"), 990 "oi": cast(ConfigNode) new ValueNode("${path.does.not.exist}"), 991 ]), 992 SubstituteEnvironmentVariables.no, 993 SubstituteConfigVariables.yes 994 ); 995 996 assert(config.get("hi") == "Hi there!"); 997 assertThrown!ConfigReadException(config.get("oi")); 998 } 999 1000 @("Do not substitute values from other config paths when disabled") 1001 unittest { 1002 auto config = new ConfigDictionary( 1003 new ObjectNode([ 1004 "greet": cast(ConfigNode) new ObjectNode([ 1005 "env": "Hi" 1006 ]), 1007 "hi": cast(ConfigNode) new ValueNode("${greet.env} there!"), 1008 "oi": cast(ConfigNode) new ValueNode("${path.does.not.exist}"), 1009 ]), 1010 SubstituteEnvironmentVariables.no, 1011 SubstituteConfigVariables.no 1012 ); 1013 1014 assert(config.get("greet.env") == "Hi"); 1015 assert(config.get("hi") == "${greet.env} there!"); 1016 assert(config.get("oi") == "${path.does.not.exist}"); 1017 } 1018 1019 @("substitute values from both environment variables and config paths") 1020 unittest { 1021 environment["MIRAGE_CONFIG_TEST_ENV_VAR_THREE"] = "punch"; 1022 1023 auto config = new ConfigDictionary( 1024 new ObjectNode([ 1025 "one": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR_THREE}"), 1026 "two": new ValueNode("${one}"), 1027 ]), 1028 SubstituteEnvironmentVariables.yes, 1029 SubstituteConfigVariables.yes 1030 ); 1031 1032 assert(config.get("two") == "punch"); 1033 } 1034 1035 @("Escaping avoids variable substitution") 1036 unittest { 1037 auto config = new ConfigDictionary(new ObjectNode([ 1038 "only dollar": "\\$ESCAPE_WITH_MY_LIFE", 1039 "with brackets": "\\${YOU WON'T GET ME}", 1040 ]) 1041 ); 1042 assert(config.get("only dollar") == "$ESCAPE_WITH_MY_LIFE"); 1043 assert(config.get("with brackets") == "${YOU WON'T GET ME}"); 1044 } 1045 1046 @("Wonky values") 1047 unittest { 1048 auto config = new ConfigDictionary(new ObjectNode([ 1049 "just a dollar": "$", 1050 "final escape": "\\", 1051 "escape with money": "\\$", 1052 "brackets": "{}", 1053 "empty bracket money": "${}", 1054 "smackers": "$$$$$", 1055 "weird bash escape thingy": "$$", 1056 "escape room": "\\\\" 1057 ])); 1058 1059 assert(config.get("just a dollar") == "$"); 1060 assert(config.get("final escape") == "\\"); 1061 assert(config.get("escape with money") == "$"); 1062 assert(config.get("brackets") == "{}"); 1063 assert(config.get("empty bracket money") == ""); 1064 assert(config.get("smackers") == "$"); 1065 assert(config.get("escape room") == "\\\\"); 1066 } 1067 1068 @("Set value at path when path does not exist") 1069 unittest { 1070 auto config = new ConfigDictionary(); 1071 config.set("do.re.mi.fa.so", "buh"); 1072 1073 assert(config.get("do.re.mi.fa.so") == "buh"); 1074 } 1075 1076 @("Set value at path when object at path exists") 1077 unittest { 1078 auto config = new ConfigDictionary( 1079 new ObjectNode( 1080 [ 1081 "one": new ObjectNode([ 1082 "two": new ObjectNode([ 1083 "mouseSound": "meep" 1084 ]) 1085 ]) 1086 ]) 1087 ); 1088 1089 config.set("one.two.catSound", "meow"); 1090 1091 assert(config.get("one.two.mouseSound") == "meep"); 1092 assert(config.get("one.two.catSound") == "meow"); 1093 } 1094 1095 @("Overwrite value at path") 1096 unittest { 1097 auto config = new ConfigDictionary( 1098 new ObjectNode( 1099 [ 1100 "one": new ObjectNode([ 1101 "two": new ObjectNode([ 1102 "mouseSound": "meep" 1103 ]) 1104 ]) 1105 ]) 1106 ); 1107 1108 config.set("one.two.mouseSound", "yo"); 1109 1110 assert(config.get("one.two.mouseSound") == "yo"); 1111 } 1112 1113 @("Diverge path completely") 1114 unittest { 1115 auto config = new ConfigDictionary( 1116 new ObjectNode( 1117 [ 1118 "one": new ObjectNode([ 1119 "two": new ObjectNode([ 1120 "mouseSound": "meep" 1121 ]) 1122 ]) 1123 ]) 1124 ); 1125 1126 config.set("I.am", "baboon"); 1127 1128 assert(config.get("one.two.mouseSound") == "meep"); 1129 assert(config.get("I.am") == "baboon"); 1130 } 1131 1132 @("Setting the same value twice will use latest setting") 1133 unittest { 1134 auto config = new ConfigDictionary(); 1135 config.set("key", "value"); 1136 config.set("key", "alsoValue"); 1137 1138 assert(config.get("key") == "alsoValue"); 1139 } 1140 }