1 /**
2  * Utilities for loading JSON 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.json;
13 
14 import std.json : JSONValue, JSONType, parseJSON;
15 import std.conv : to;
16 
17 import mirage.config : ConfigFactory, ConfigDictionary, ConfigNode, ValueNode, ObjectNode, ArrayNode, ConfigCreationException;
18 
19 /** 
20  * Creates configuration dictionaries from JSONs.
21  */
22 class JsonConfigFactory : ConfigFactory {
23 
24     /**
25      * Parse configuration from the given JSON string.
26      *
27      * Params:
28      *   contents = Text contents of the config to be parsed.
29      * Returns: The parsed configuration.
30      */
31     override ConfigDictionary parseConfig(string contents) {
32         return parseJson(parseJSON(contents));
33     }
34 
35     /** 
36      * Parse configuration from a JSONValue tree. 
37      *
38      * Params:
39      *   json = JSONValue config to be parsed.
40      * Returns: The parsed configuration.
41      */
42     ConfigDictionary parseJson(JSONValue json) {
43         return new ConfigDictionary(convertJValue(json));
44     }
45 
46     /** 
47      * Alias for parseConfig
48      *
49      * Params:
50      *   json = Text contents of the config to be parsed.
51      * Returns: The parsed configuration.
52      * See_Also: parseConfig
53      */
54     ConfigDictionary parseJson(string json) {
55         return parseConfig(json);
56     }
57 
58     private ConfigNode convertJValue(JSONValue json) {
59         if (json.type() == JSONType.object) {
60             auto objectNode = new ObjectNode();
61             auto objectJson = json.object();
62             foreach (propertyName, jvalue; objectJson) {
63                 objectNode.children[propertyName] = convertJValue(jvalue);
64             }
65 
66             return objectNode;
67         }
68 
69         if (json.type() == JSONType.array) {
70             auto arrayNode = new ArrayNode();
71             auto arrayJson = json.array();
72             foreach (jvalue; arrayJson) {
73                 arrayNode.children ~= convertJValue(jvalue);
74             }
75 
76             return arrayNode;
77         }
78 
79         if (json.type() == JSONType.null_) {
80             return new ValueNode(null);
81         }
82 
83         if (json.type() == JSONType..string) {
84             return new ValueNode(json.get!string);
85         }
86 
87         if (json.type() == JSONType.integer) {
88             return new ValueNode(json.integer.to!string);
89         }
90 
91         if (json.type() == JSONType.float_) {
92             return new ValueNode(json.floating.to!string);
93         }
94 
95         throw new ConfigCreationException("JSONValue is not supported: " ~ json.toString());
96     }
97 }
98 
99 /** 
100  * Parse JSON config from the given JSON string.
101  *
102  * Params:
103  *   json = Text contents of the config to be parsed.
104  * Returns: The parsed configuration.
105  */
106 ConfigDictionary parseJsonConfig(const string json) {
107     return new JsonConfigFactory().parseConfig(json);
108 }
109 
110 /** 
111  * Parse JSON config from the given JSONValue.
112  *
113  * Params:
114  *   json = JSONValue config to be parsed.
115  * Returns: The parsed configuration.
116  */
117 ConfigDictionary parseJsonConfig(const JSONValue json) {
118     return new JsonConfigFactory().parseJson(json);
119 }
120 
121 /** 
122  * Load a JSON configuration file from disk.
123  *
124  * Params:
125  *   filePath = Path to the JSON configuration file.
126  * Returns: The loaded configuration.
127  */
128 ConfigDictionary loadJsonConfig(const string filePath) {
129     return new JsonConfigFactory().loadFile(filePath);
130 }
131 
132 version (unittest) {
133     import std.process : environment;
134 
135     @("Parse JSON")
136     unittest {
137         JSONValue serverJson = ["hostname": "hosty.com", "port": "1234"];
138         JSONValue nullJson = ["isNull": null];
139         JSONValue socketsJson = [
140             "/var/sock/one", "/var/sock/two", "/var/sock/three"
141         ];
142         JSONValue numbersJson = [1, 2, 3, 4, -7];
143         JSONValue decimalsJson = [1.2, 4.5, 6.7];
144         JSONValue jsonConfig = [
145             "server": serverJson, "sockets": socketsJson, "nully": nullJson,
146             "numberos": numbersJson, "decimalas": decimalsJson
147         ];
148 
149         auto config = parseJsonConfig(jsonConfig);
150 
151         assert(config.get("server.hostname") == "hosty.com");
152         assert(config.get("server.port") == "1234");
153         assert(config.get("sockets[2]") == "/var/sock/three");
154         assert(config.get("nully.isNull") == null);
155         assert(config.get("numberos[3]") == "4");
156         assert(config.get("numberos[4]") == "-7");
157         assert(config.get("decimalas[0]") == "1.2");
158         assert(config.get("decimalas[2]") == "6.7");
159     }
160 
161     @("Parse JSON root values")
162     unittest {
163         assert(parseJsonConfig(JSONValue("hi")).get(".") == "hi");
164         assert(parseJsonConfig(JSONValue(1)).get(".") == "1");
165         assert(parseJsonConfig(JSONValue(null)).get(".") == null);
166         assert(parseJsonConfig(JSONValue(1.8)).get(".") == "1.8");
167         assert(parseJsonConfig(JSONValue([1, 2, 3])).get("[2]") == "3");
168     }
169 
170     @("Parse JSON string")
171     unittest {
172         string json = "
173             {
174                 \"name\": \"Groot\",
175                 \"traits\": [\"groot\", \"tree\"],
176                 \"age\": 8728,
177                 \"taxNumber\": null
178             } 
179         ";
180 
181         auto config = parseJsonConfig(json);
182 
183         assert(config.get("name") == "Groot");
184         assert(config.get("traits[1]") == "tree");
185         assert(config.get("age") == "8728");
186         assert(config.get("taxNumber") == null);
187     }
188 
189     @("Load JSON file")
190     unittest {
191         auto config = loadJsonConfig("testfiles/groot.json");
192 
193         assert(config.get("name") == "Groot");
194         assert(config.get("traits[1]") == "tree");
195         assert(config.get("age") == "8728");
196         assert(config.get("taxNumber") == null);
197     }
198 
199     @("Substitute env vars")
200     unittest {
201         environment["MIRAGE_TEST_APP_NAME"] = "Unittest";
202         environment["MIRAGE_TEST_HOSTNAME"] = "wonkeyhost";
203         environment.remove("MIRAGE_TEST_PORT");
204 
205         auto config = loadJsonConfig("testfiles/server.json");
206 
207         assert(config.get("server.host") == "wonkeyhost");
208         assert(config.get("server.port") == "8118");
209         assert(config.get("app") == "Unittest server - built with love");
210     }
211 
212     @("Use value from other key")
213     unittest {
214         string json = "
215             {
216                 \"one\": \"Groot\",
217                 \"two\": \"${one}\",
218             } 
219         ";
220 
221         auto config = parseJsonConfig(json);
222 
223         assert(config.get("two") == "Groot");
224     }
225 }