/** * Base utilities for working with configurations. * * Authors: * Mike Bierlee, m.bierlee@lostmoment.com * Copyright: 2022 Mike Bierlee * License: * This software is licensed under the terms of the MIT license. * The full terms of the license can be found in the LICENSE file. */ module mirage.config; import std.exception : enforce; import std.string : split, startsWith, endsWith, join, lastIndexOf, strip, toLower; import std.conv : to, ConvException; import std.file : readText; import std.path : extension; import mirage.json : loadJsonConfig; /** * Used by the ConfigDictionary when something goes wrong when reading configuration. */ class ConfigReadException : Exception { this(string msg, string file = __FILE__, size_t line = __LINE__) { super(msg, file, line); } } /** * Used by ConfigFactory instances when loading or parsing configuration fails. */ class ConfigCreationException : Exception { this(string msg, string file = __FILE__, size_t line = __LINE__) { super(msg, file, line); } } /** * Used by ConfigDictionary when there is something wrong with the path when calling ConfigDictionary.get() */ class PathParseException : Exception { this(string msg, string path, string file = __FILE__, size_t line = __LINE__) { string fullMsg = msg ~ " (Path: " ~ path ~ ")"; super(fullMsg, file, line); } } /** * The configuration tree is made up of specific types of ConfigNodes. * Used as generic type for ConfigFactory and ConfigDictionary. */ interface ConfigNode { string nodeType(); } /** * A configuration item that is any sort of primitive value (strings, numbers or null). */ class ValueNode : ConfigNode { string value; this() { } this(string value) { this.value = value; } string nodeType() { return "value"; } } /** * A configuration item that is an object. * * ObjectNodes contain a node dictionary that points to other ConfigNodes. */ class ObjectNode : ConfigNode { ConfigNode[string] children; this() { } this(ConfigNode[string] children) { this.children = children; } this(string[string] values) { foreach (key, value; values) { children[key] = new ValueNode(value); } } string nodeType() { return "object"; } } /** * A configuration item that is an array. * * Contains other ConfigNodes as children. */ class ArrayNode : ConfigNode { ConfigNode[] children; this() { } this(ConfigNode[] children...) { this.children = children; } this(string[] values...) { foreach (string value; values) { children ~= new ValueNode(value); } } string nodeType() { return "array"; } } private interface PathSegment { } private class ArrayPathSegment : PathSegment { const size_t index; this(const size_t index) { this.index = index; } } private class PropertyPathSegment : PathSegment { const string propertyName; this(const string propertyName) { this.propertyName = propertyName; } } private class ConfigPath { private const string path; private string[] previousSegments; private string[] segments; this(const string path) { this.path = path; segmentAndNormalize(path); } private void segmentAndNormalize(string path) { foreach (segment; path.split(".")) { auto trimmedSegment = segment.strip; if (trimmedSegment.length <= 0) { continue; } if (trimmedSegment.endsWith("]") && !trimmedSegment.startsWith("[")) { auto openBracketPos = trimmedSegment.lastIndexOf("["); if (openBracketPos != -1) { segments ~= trimmedSegment[0 .. openBracketPos]; segments ~= trimmedSegment[openBracketPos .. $]; continue; } } segments ~= trimmedSegment; } } PathSegment getNextSegment() { if (segments.length == 0) { return null; } PathSegment ret(PathSegment segment) { previousSegments ~= segments[0]; segments = segments[1 .. $]; return segment; } string segment = segments[0]; if (segment.startsWith("[") && segment.endsWith("]")) { if (segment.length <= 2) { throw new PathParseException("Path has array accessor but no index specified", path); } auto indexString = segment[1 .. $ - 1]; try { auto index = indexString.to!size_t; return ret(new ArrayPathSegment(index)); } catch (ConvException e) { throw new PathParseException("Value '" ~ indexString ~ "' is not acceptable as an array index", path); } } return ret(new PropertyPathSegment(segment)); } string getCurrentPath() { return previousSegments.join("."); } } /** * A ConfigDictionary contains the configuration tree and facilities to get values from that tree. */ class ConfigDictionary { ConfigNode rootNode; this() { } this(ConfigNode rootNode) { this.rootNode = rootNode; } /** * Get values from the configuration using config path notation. * * Params: * configPath = Path to the wanted config value. The path is separated by dots, e.g. "server.public.hostname". * Values from arrays can be selected by brackets, for example: "server[3].hostname.ports[0]". * When the config is just a value, for example just a string, it can be fetched by just specifying "." as path. * Although the path should be universally the same over all types of config files, some might not lend to this structure, * and have a more specific way of retrieving data from the config. See the examples and specific config factories for * more details. * * Returns: The value at the path in the configuration. To convert it use get!T(). */ string get(string configPath) { auto path = new ConfigPath(configPath); auto node = getNodeAt(path); auto value = cast(ValueNode) node; if (value) { return value.value; } else { throw new ConfigReadException( "Value expected but " ~ node.nodeType ~ " found at path: " ~ createExceptionPath( path)); } } /** * Get values from the configuration and attempts to convert them to the specified type. * * Params: * configPath = Path to the wanted config value. See get(). * Returns: The value at the path in the configuration. */ ConvertToType get(ConvertToType)(string configPath) { return get(configPath).to!ConvertToType; } /** * Fetch a sub-section of the config as another config. * * Commonly used for example to fetch further configuration from arrays, e.g.: `getConfig("http.servers[3]")` * which then returns the rest of the config at that path. * * Params: * configPath = Path to the wanted config. See get(). * Returns: A sub-section of the configuration. */ ConfigDictionary getConfig(string configPath) { auto path = new ConfigPath(configPath); auto node = getNodeAt(path); return new ConfigDictionary(node); } string createExceptionPath(ConfigPath path) { return "'" ~ path.path ~ "' (at '" ~ path.getCurrentPath() ~ "')"; } private ConfigNode getNodeAt(ConfigPath path) { enforce!ConfigReadException(rootNode !is null, "The config is empty"); auto currentNode = rootNode; PathSegment currentPathSegment = path.getNextSegment(); void throwPathNotExists() { throw new ConfigReadException("Path does not exist: " ~ createExceptionPath(path)); } void ifNotNullPointer(void* obj, void delegate() fn) { if (obj) { fn(); } else { throwPathNotExists(); } } void ifNotNull(Object obj, void delegate() fn) { if (obj) { fn(); } else { throwPathNotExists(); } } while (currentPathSegment !is null) { if (currentNode is null) { throwPathNotExists(); } auto valueNode = cast(ValueNode) currentNode; if (valueNode) { throwPathNotExists(); } auto arrayPath = cast(ArrayPathSegment) currentPathSegment; if (arrayPath) { auto arrayNode = cast(ArrayNode) currentNode; ifNotNull(arrayNode, { if (arrayNode.children.length < arrayPath.index) { throw new ConfigReadException( "Array index out of bounds: " ~ createExceptionPath(path)); } currentNode = arrayNode.children[arrayPath.index]; }); } auto propertyPath = cast(PropertyPathSegment) currentPathSegment; if (propertyPath) { auto objectNode = cast(ObjectNode) currentNode; ifNotNull(objectNode, { auto propertyNode = propertyPath.propertyName in objectNode.children; ifNotNullPointer(propertyNode, { currentNode = *propertyNode; }); }); } currentPathSegment = path.getNextSegment(); } return currentNode; } } /** * The base class used by configuration factories for specific file types. */ abstract class ConfigFactory { /** * Loads a configuration from the specified path from disk. * * Params: * path = Path to file. OS dependent, but UNIX paths are generally working. * Returns: The parsed configuration. */ ConfigDictionary loadFile(string path) { auto json = readText(path); return parseConfig(json); } /** * Parse configuration from the given string. * * Params: * contents = Text contents of the config to be parsed. * Returns: The parsed configuration. */ ConfigDictionary parseConfig(string contents); } ConfigDictionary loadConfig(const string configPath) { auto extension = configPath.extension.toLower; if (extension == ".json") { return loadJsonConfig(configPath); } throw new ConfigCreationException( "File extension '" ~ extension ~ "' is not recognized as a supported config file format. Please use a specific function to load it, such as 'loadJsonConfig()'"); } version (unittest) { import std.exception : assertThrown; import std.math.operations : isClose; @("Dictionary creation") unittest { auto root = new ObjectNode([ "english": new ArrayNode([new ValueNode("one"), new ValueNode("two")]), "spanish": new ArrayNode(new ValueNode("uno"), new ValueNode("dos")) ]); auto config = new ConfigDictionary(); config.rootNode = root; } @("Get value in config with empty root fails") unittest { auto config = new ConfigDictionary(); assertThrown!ConfigReadException(config.get(".")); } @("Get value in root with empty path") unittest { auto config = new ConfigDictionary(new ValueNode("hehehe")); assert(config.get("") == "hehehe"); } @("Get value in root with just a dot") unittest { auto config = new ConfigDictionary(new ValueNode("yup")); assert(config.get(".") == "yup"); } @("Get value in root fails when root is not a value") unittest { auto config = new ConfigDictionary(new ArrayNode()); assertThrown!ConfigReadException(config.get(".")); } @("Get array value from root") unittest { auto config = new ConfigDictionary(new ArrayNode("aap", "noot", "mies")); assert(config.get("[0]") == "aap"); assert(config.get("[1]") == "noot"); assert(config.get("[2]") == "mies"); } @("Get value from object at root") unittest { auto config = new ConfigDictionary(new ObjectNode([ "aap": "monkey", "noot": "nut", "mies": "mies" // It's a name! ]) ); assert(config.get("aap") == "monkey"); assert(config.get("noot") == "nut"); assert(config.get("mies") == "mies"); } @("Get value from object in object") unittest { auto config = new ConfigDictionary( new ObjectNode([ "server": new ObjectNode([ "port": "8080" ]) ]) ); assert(config.get("server.port") == "8080"); } @("Get value from array in object") unittest { auto config = new ConfigDictionary( new ObjectNode([ "hostname": new ArrayNode(["google.com", "dlang.org"]) ]) ); assert(config.get("hostname.[1]") == "dlang.org"); } @("Exception is thrown when array out of bounds when fetching from root") unittest { auto config = new ConfigDictionary( new ArrayNode([ "google.com", "dlang.org" ]) ); assertThrown!ConfigReadException(config.get("[5]")); } @("Exception is thrown when array out of bounds when fetching from object") unittest { auto config = new ConfigDictionary( new ObjectNode([ "hostname": new ArrayNode(["google.com", "dlang.org"]) ]) ); assertThrown!ConfigReadException(config.get("hostname.[5]")); } @("Exception is thrown when path does not exist") unittest { auto config = new ConfigDictionary(new ObjectNode( [ "hostname": new ObjectNode(["cluster": new ValueNode("")]) ]) ); assertThrown!ConfigReadException(config.get("hostname.cluster.spacey")); } @("Exception is thrown when given path terminates too early") unittest { auto config = new ConfigDictionary(new ObjectNode( [ "hostname": new ObjectNode(["cluster": new ValueNode(null)]) ]) ); assertThrown!ConfigReadException(config.get("hostname")); } @("Exception is thrown when given path does not exist because config is an array") unittest { auto config = new ConfigDictionary(new ArrayNode()); assertThrown!ConfigReadException(config.get("hostname")); } @("Get value from objects in array") unittest { auto config = new ConfigDictionary(new ArrayNode( new ObjectNode(["wrong": "yes"]), new ObjectNode(["wrong": "no"]), new ObjectNode(["wrong": "very"]), )); assert(config.get("[1].wrong") == "no"); } @("Get value from config with mixed types") unittest { auto config = new ConfigDictionary( new ObjectNode([ "uno": cast(ConfigNode) new ValueNode("one"), "dos": cast(ConfigNode) new ArrayNode(["nope", "two"]), "tres": cast(ConfigNode) new ObjectNode(["thisone": "three"]) ]) ); assert(config.get("uno") == "one"); assert(config.get("dos.[1]") == "two"); assert(config.get("tres.thisone") == "three"); } @("Ignore empty segments") unittest { auto config = new ConfigDictionary( new ObjectNode( [ "one": new ObjectNode(["two": new ObjectNode(["three": "four"])]) ]) ); assert(config.get(".one..two...three....") == "four"); } @("Support conventional array indexing notation") unittest { auto config = new ConfigDictionary( new ObjectNode( [ "one": new ObjectNode([ "two": new ArrayNode(["dino", "mino"]) ]) ]) ); assert(config.get("one.two[1]") == "mino"); } @("Get and convert values") unittest { auto config = new ConfigDictionary( new ObjectNode([ "uno": new ValueNode("1223"), "dos": new ValueNode("true"), "tres": new ValueNode("Hi you"), "quatro": new ValueNode("1.3") ]) ); assert(config.get!int("uno") == 1223); assert(config.get!bool("dos") == true); assert(config.get!string("tres") == "Hi you"); assert(isClose(config.get!float("quatro"), 1.3)); } @("Get config from array") unittest { auto configOne = new ConfigDictionary(new ObjectNode( [ "servers": new ArrayNode([ new ObjectNode(["hostname": "lala.com"]), new ObjectNode(["hostname": "lele.com"]) ]) ]) ); auto config = configOne.getConfig("servers[0]"); assert(config.get("hostname") == "lala.com"); } @("Trim spaces in path segments") unittest { auto config = new ConfigDictionary( new ObjectNode(["que": new ObjectNode(["pasa hombre": "not much"])]) ); assert(config.get(" que. pasa hombre ") == "not much"); } @("Load configurations using the loadConfig convenience function") unittest { auto jsonConfig = loadConfig("testfiles/groot.json"); assert(jsonConfig.get("name") == "Groot"); assert(jsonConfig.get("traits[1]") == "tree"); assert(jsonConfig.get("age") == "8728"); assert(jsonConfig.get("taxNumber") == null); } }