Created
March 14, 2019 09:09
-
-
Save digulla/15a55d8a5cefb2bda1e2da1fb6dae4f7 to your computer and use it in GitHub Desktop.
Code for a nested config that can validate path and type errors
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ConfigException extends RuntimeException { | |
ConfigException(String message) { | |
super(message) | |
} | |
ConfigException(String message, Throwable cause) { | |
super(message, cause) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import static org.junit.Assert.* | |
import org.junit.Test | |
class DefaultConfigTest { | |
@Test | |
void testSimpleConfig() { | |
def defConfig = new ConfigMap([ | |
foo: 'bar' | |
]) | |
assert 'bar' == defConfig.foo | |
} | |
@Test | |
void testNestedConfig() { | |
def defConfig = new ConfigMap([ | |
foo: [ | |
bar: 'baz' | |
] | |
]) | |
assert 'baz' == defConfig.foo.bar | |
} | |
@Test | |
void testMerge() { | |
def defConfig = new ConfigMap([ | |
foo: 'bar' | |
]) | |
def config = new ConfigMap([ | |
foo: 'xxx' | |
]) | |
def merged = defConfig.merge(config) | |
assert 'xxx' == merged.foo | |
} | |
@Test | |
void testMergeNested() { | |
def defConfig = new ConfigMap([ | |
foo: [ | |
bar: 'baz' | |
] | |
]) | |
def config = new ConfigMap([ | |
foo: [ | |
bar: 'xxx' | |
] | |
]) | |
def merged = defConfig.merge(config) | |
assert 'xxx' == merged.foo.bar | |
} | |
@Test | |
void testValidatePaths() { | |
def defConfig = new ConfigMap([ | |
foo: [ | |
bar: 'baz' | |
] | |
]) | |
def config = new ConfigMap([ | |
foo: [ | |
var: 'xxx' // Typo | |
] | |
]) | |
try { | |
defConfig.merge(config) | |
} catch(ConfigException e) { | |
assertEquals("Unknown config option [foo.var]; valid options in [foo] are: [bar]", e.message) | |
} | |
} | |
@Test | |
void testMapInsteadOfString() { | |
def defConfig = new ConfigMap([ | |
foo: 'bar' | |
]) | |
def config = new ConfigMap([ | |
foo: [ | |
var: 'xxx' // Typo | |
] | |
]) | |
try { | |
defConfig.merge(config) | |
} catch(ConfigException e) { | |
assertEquals("Config option [foo] expects nested map but got class java.lang.String", e.message) | |
} | |
} | |
@Test | |
void testMapInsteadOfList() { | |
def defConfig = new ConfigMap([ | |
foo: ['bar'] | |
]) | |
def config = new ConfigMap([ | |
foo: [ | |
var: 'xxx' // Typo | |
] | |
]) | |
try { | |
defConfig.merge(config) | |
} catch(ConfigException e) { | |
assertEquals("Config option [foo] expects nested map but got class java.util.ArrayList", e.message) | |
} | |
} | |
@Test | |
void testStringInsteadOfList() { | |
def defConfig = new ConfigMap([ | |
foo: ['bar'] | |
]) | |
def config = new ConfigMap([ | |
foo: 'xxx' | |
]) | |
try { | |
defConfig.merge(config) | |
} catch(ConfigException e) { | |
assertEquals("Config option [foo] is a class java.util.ArrayList, not a class java.lang.String", e.message) | |
} | |
} | |
@Test | |
void testMapInsteadOfSet() { | |
def defConfig = new ConfigMap([ | |
foo: new LinkedHashSet(['bar']) | |
]) | |
def config = new ConfigMap([ | |
foo: [ | |
var: 'xxx' // Typo | |
] | |
]) | |
try { | |
defConfig.merge(config) | |
} catch(ConfigException e) { | |
assertEquals("Config option [foo] expects nested map but got class java.util.LinkedHashSet", e.message) | |
} | |
} | |
@Test | |
void testIntInsteadOfString() { | |
def defConfig = new ConfigMap([ | |
foo: '1' | |
]) | |
def config = new ConfigMap([ | |
foo: 2 | |
]) | |
try { | |
defConfig.merge(config) | |
} catch(ConfigException e) { | |
assertEquals("Config option [foo] is a class java.lang.String, not a class java.lang.Integer", e.message) | |
} | |
} | |
@Test | |
void testExtend() { | |
def defConfig = new ConfigMap([ | |
'foo': 'bar' | |
]) | |
def extended = defConfig.extend(new ConfigMap([ | |
'x': 'y' | |
])) | |
assertEquals('[foo:bar]', defConfig.toString()) | |
assertEquals('[foo:bar, x:y]', extended.toString()) | |
} | |
@Test | |
void testExtendNested() { | |
def defConfig = new ConfigMap([ | |
foo: [ | |
bar: 'baz' | |
] | |
]) | |
def extended = defConfig.extend(new ConfigMap([ | |
foo: [ | |
a: 'b' | |
], | |
'x': 'y' | |
])) | |
assertEquals('[foo:[bar:baz]]', defConfig.toString()) | |
assertEquals('[foo:[a:b, bar:baz], x:y]', extended.toString()) | |
} | |
@Test | |
void testExtendPathCollision() { | |
def defConfig = new ConfigMap([ | |
'foo': 'bar' | |
]) | |
try { | |
defConfig.extend(new ConfigMap([ | |
'foo': 1 | |
])) | |
} catch(ConfigException e) { | |
assertEquals("The path foo already exists", e.message) | |
} | |
} | |
@Test | |
void testGetConfig() { | |
def config = new ConfigMap([ | |
'scm': [ | |
'git': [ | |
'url': 'https://github.com/...' | |
] | |
] | |
]) | |
def child = config.getConfig('scm') | |
assertEquals('[git:[url:https://github.com/...]]', child.toString()) | |
child = child.getConfig('git') | |
assertEquals('[url:https://github.com/...]', child.toString()) | |
child = config.getConfig('scm.git') | |
assertEquals('[url:https://github.com/...]', child.toString()) | |
} | |
@Test | |
void testGetConfigWrongPath() { | |
def config = new ConfigMap([ | |
'scm': [ | |
'git': [ | |
'url': 'https://github.com/...' | |
] | |
] | |
]) | |
try { | |
config.getConfig('scm.gut') | |
} catch(ConfigException e) { | |
assertEquals("No nested config [gut] found at [scm]; possible values are: [git]", e.message) | |
} | |
} | |
} | |
public class ConfigMap extends TreeMap<String, Object> { | |
String path | |
ConfigMap(Map<String, Object> map = [:], String path = '') { | |
assert map != null | |
assert path != null | |
this.path = path | |
init(map) | |
} | |
void init(Map<String, Object> map) { | |
for (def entry: map) { | |
String key = entry.key | |
def value = entry.value | |
if (value instanceof Map) { | |
def childPath = childPath(key) | |
value = new ConfigMap(value, childPath) | |
} else if (value instanceof Set) { | |
value = new LinkedHashSet(value) | |
} else if (value instanceof List) { | |
// List of Map/Config isn't supported, because we can't build a path for them | |
value = new ArrayList(value) | |
} | |
println("${path}: init.put(${key}=${value})") | |
super.put(key, value) | |
} | |
} | |
String childPath(String key) { | |
return (path.length() > 0) ? "${path}.${key}" : key | |
} | |
Object get(String key) { | |
def result = super.get(key) | |
if (result == null && !containsKey(key)) { | |
String myPath = path.length() > 0 ? " in [${path}]" : "" | |
throw new ConfigException("Unknown config option [${childPath(key)}]; valid options${myPath} are: ${keySet()}") | |
} | |
return result | |
} | |
ConfigMap getConfig(String path) { | |
def parts = path.split('\\.', 2) | |
String child | |
String rest | |
if (parts.length == 2) { | |
child = parts[0] | |
rest = parts[1] | |
} else { | |
child = parts[0] | |
rest = null | |
} | |
def result = super.get(child) | |
if (result instanceof ConfigMap) { | |
if (rest == null) { | |
return result | |
} else { | |
return result.getConfig(rest) | |
} | |
} | |
def possibleValues = [] | |
for(def entry: this) { | |
if (entry.value instanceof ConfigMap) { | |
possibleValues << entry.key | |
} | |
} | |
throw new ConfigException("No nested config [${child}] found at [${this.path}]; possible values are: ${possibleValues}") | |
} | |
/** Merge user preferences into defaults. Returns a new instance. All nested structures (maps, lists, sets) are copied shallowly. */ | |
ConfigMap merge(ConfigMap overrides) { | |
println("Merging\n${this}\nwith\n${overrides}") | |
ConfigMap result = new ConfigMap(this) | |
result.recursiveMerge(overrides) | |
return result | |
} | |
void recursiveMerge(ConfigMap overrides) { | |
for (def entry: overrides) { | |
String key = entry.key | |
def value = entry.value | |
def child = get(key) // Side effect: verify key | |
if (value instanceof Map) { | |
if (!(child instanceof ConfigMap)) { | |
throw new ConfigException("Config option [${childPath(key)}] expects nested map but got ${get(key).getClass()}") | |
} | |
child.recursiveMerge(value) | |
continue | |
} else if (value instanceof Set) { | |
if (!(child instanceof Set)) { | |
throw new ConfigException("Config option [${childPath(key)}] expects set but got ${get(key).getClass()}") | |
} | |
value = new LinkedHashSet(value) | |
} else if (value instanceof List) { | |
if (!(child instanceof List)) { | |
throw new ConfigException("Config option [${childPath(key)}] expects list but got ${get(key).getClass()}") | |
} | |
value = new ArrayList(value) | |
} else if (child != null && value != null) { | |
if (!(child.getClass().isAssignableFrom(value.getClass()))) { | |
throw new ConfigException("Config option [${childPath(key)}] is a ${get(key).getClass()}, not a ${value.getClass()}") | |
} | |
} | |
super.put(key, value) | |
} | |
} | |
/** Extend an existing config. The extension must not contain the any existing keys. Returns a new instance. Nested structures are not copied. */ | |
ConfigMap extend(ConfigMap extension) { | |
ConfigMap result = new ConfigMap(this) | |
result.recursiveExtend(extension) | |
return result | |
} | |
void recursiveExtend(ConfigMap extension) { | |
for(def entry: extension) { | |
String key = entry.key | |
def value = entry.value | |
if (containsKey(key)) { | |
def child = get(key) | |
if (child instanceof ConfigMap) { | |
if (value instanceof ConfigMap) { | |
child.recursiveExtend(value) | |
} else { | |
throw new ConfigException("The path ${childPath(key)} expects a nested map, not ${value.getClass()}") | |
} | |
} else { | |
throw new ConfigException("The path ${childPath(key)} already exists") | |
} | |
} else { | |
super.put(key, value) | |
} | |
} | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment