diff --git a/config/src/main/java/com/typesafe/config/ConfigResolveOptions.java b/config/src/main/java/com/typesafe/config/ConfigResolveOptions.java index 2b7140416..96e0eca0a 100644 --- a/config/src/main/java/com/typesafe/config/ConfigResolveOptions.java +++ b/config/src/main/java/com/typesafe/config/ConfigResolveOptions.java @@ -29,10 +29,13 @@ public final class ConfigResolveOptions { private final boolean useSystemEnvironment; private final boolean allowUnresolved; + private final ConfigResolver resolver; - private ConfigResolveOptions(boolean useSystemEnvironment, boolean allowUnresolved) { + private ConfigResolveOptions(boolean useSystemEnvironment, boolean allowUnresolved, + ConfigResolver resolver) { this.useSystemEnvironment = useSystemEnvironment; this.allowUnresolved = allowUnresolved; + this.resolver = resolver; } /** @@ -42,7 +45,7 @@ private ConfigResolveOptions(boolean useSystemEnvironment, boolean allowUnresolv * @return the default resolve options */ public static ConfigResolveOptions defaults() { - return new ConfigResolveOptions(true, false); + return new ConfigResolveOptions(true, false, NULL_RESOLVER); } /** @@ -64,7 +67,7 @@ public static ConfigResolveOptions noSystem() { * @return options with requested setting for use of environment variables */ public ConfigResolveOptions setUseSystemEnvironment(boolean value) { - return new ConfigResolveOptions(value, allowUnresolved); + return new ConfigResolveOptions(value, allowUnresolved, resolver); } /** @@ -91,7 +94,55 @@ public boolean getUseSystemEnvironment() { * @since 1.2.0 */ public ConfigResolveOptions setAllowUnresolved(boolean value) { - return new ConfigResolveOptions(useSystemEnvironment, value); + return new ConfigResolveOptions(useSystemEnvironment, value, resolver); + } + + /** + * Returns options where the given resolver used as a fallback if a + * reference cannot be otherwise resolved. This resolver will only be called + * after resolution has failed to substitute with a value from within the + * config itself and with any other resolvers that have been appended before + * this one. Multiple resolvers can be added using, + * + *
+     *     ConfigResolveOptions options = ConfigResolveOptions.defaults()
+     *         .appendResolver(primary)
+     *         .appendResolver(secondary)
+     *         .appendResolver(tertiary);
+     * 
+ * + * With this config unresolved references will first be resolved with the + * primary resolver, if that fails then the secondary, and finally if that + * also fails the tertiary. + * + * If all fallbacks fail to return a substitution "allow unresolved" + * determines whether resolution fails or continues. + *` + * @param value the resolver to fall back to + * @return options that use the given resolver as a fallback + * @since 1.3.2 + */ + public ConfigResolveOptions appendResolver(ConfigResolver value) { + if (value == null) { + throw new ConfigException.BugOrBroken("null resolver passed to appendResolver"); + } else if (value == this.resolver) { + return this; + } else { + return new ConfigResolveOptions(useSystemEnvironment, allowUnresolved, + this.resolver.withFallback(value)); + } + } + + /** + * Returns the resolver to use as a fallback if a substitution cannot be + * otherwise resolved. Never returns null. This method is mostly used by the + * config lib internally, not by applications. + * + * @return the non-null fallback resolver + * @since 1.3.2 + */ + public ConfigResolver getResolver() { + return this.resolver; } /** @@ -104,4 +155,22 @@ public ConfigResolveOptions setAllowUnresolved(boolean value) { public boolean getAllowUnresolved() { return allowUnresolved; } + + /** + * Singleton resolver that never resolves paths. + */ + private static final ConfigResolver NULL_RESOLVER = new ConfigResolver() { + + @Override + public ConfigValue lookup(String path) { + return null; + } + + @Override + public ConfigResolver withFallback(ConfigResolver fallback) { + return fallback; + } + + }; + } diff --git a/config/src/main/java/com/typesafe/config/ConfigResolver.java b/config/src/main/java/com/typesafe/config/ConfigResolver.java new file mode 100644 index 000000000..a380a04f4 --- /dev/null +++ b/config/src/main/java/com/typesafe/config/ConfigResolver.java @@ -0,0 +1,38 @@ +package com.typesafe.config; + +/** + * Implement this interface and provide an instance to + * {@link ConfigResolveOptions#appendResolver ConfigResolveOptions.appendResolver()} + * to provide custom behavior when unresolved substitutions are encountered + * during resolution. + * @since 1.3.2 + */ +public interface ConfigResolver { + + /** + * Returns the value to substitute for the given unresolved path. To get the + * components of the path use {@link ConfigUtil#splitPath(String)}. If a + * non-null value is returned that value will be substituted, otherwise + * resolution will continue to consider the substitution as still + * unresolved. + * + * @param path the unresolved path + * @return the value to use as a substitution or null + */ + public ConfigValue lookup(String path); + + /** + * Returns a new resolver that falls back to the given resolver if this + * one doesn't provide a substitution itself. + * + * It's important to handle the case where you already have the fallback + * with a "return this", i.e. this method should not create a new object if + * the fallback is the same one you already have. The same fallback may be + * added repeatedly. + * + * @param fallback the previous includer for chaining + * @return a new resolver + */ + public ConfigResolver withFallback(ConfigResolver fallback); + +} diff --git a/config/src/main/java/com/typesafe/config/impl/ConfigReference.java b/config/src/main/java/com/typesafe/config/impl/ConfigReference.java index 8d3c7c0c0..077503d40 100644 --- a/config/src/main/java/com/typesafe/config/impl/ConfigReference.java +++ b/config/src/main/java/com/typesafe/config/impl/ConfigReference.java @@ -6,6 +6,8 @@ import com.typesafe.config.ConfigException; import com.typesafe.config.ConfigOrigin; import com.typesafe.config.ConfigRenderOptions; +import com.typesafe.config.ConfigResolveOptions; +import com.typesafe.config.ConfigValue; import com.typesafe.config.ConfigValueType; /** @@ -88,7 +90,8 @@ ResolveResult resolveSubstitutions(ResolveContext v = result.value; newContext = result.context; } else { - v = null; + ConfigValue fallback = context.options().getResolver().lookup(expr.path().render()); + v = (AbstractConfigValue) fallback; } } catch (NotPossibleToResolve e) { if (ConfigImpl.traceSubstitutionsEnabled()) diff --git a/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala b/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala index d88b365c4..96b55835d 100644 --- a/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala +++ b/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala @@ -1233,4 +1233,70 @@ class ConfigTest extends TestUtils { val resolved = unresolved.resolveWith(source) assertEquals(43, resolved.getInt("foo")) } + + /** + * A resolver that replaces paths that start with a particular prefix with + * strings where that prefix has been replaced with another prefix. + */ + class DummyResolver(prefix: String, newPrefix: String, fallback: ConfigResolver) extends ConfigResolver { + + override def lookup(path: String): ConfigValue = { + if (path.startsWith(prefix)) + ConfigValueFactory.fromAnyRef(newPrefix + path.substring(prefix.length)) + else if (fallback != null) + fallback.lookup(path) + else + null + } + + override def withFallback(f: ConfigResolver): ConfigResolver = { + if (fallback == null) + new DummyResolver(prefix, newPrefix, f) + else + new DummyResolver(prefix, newPrefix, fallback.withFallback(f)) + } + + } + + private def runFallbackTest(expected: String, source: String, + allowUnresolved: Boolean, resolvers: ConfigResolver*) = { + val unresolved = ConfigFactory.parseString(source) + var options = ConfigResolveOptions.defaults().setAllowUnresolved(allowUnresolved) + for (resolver <- resolvers) + options = options.appendResolver(resolver) + val obj = unresolved.resolve(options).root() + assertEquals(expected, obj.render(ConfigRenderOptions.concise().setJson(false))) + } + + @Test + def resolveFallback(): Unit = { + runFallbackTest( + "x=a,y=b", + "x=${a},y=${b}", false, + new DummyResolver("", "", null)) + runFallbackTest( + "x=\"a.b.c\",y=\"a.b.d\"", + "x=${a.b.c},y=${a.b.d}", false, + new DummyResolver("", "", null)) + runFallbackTest( + "x=${a.b.c},y=${a.b.d}", + "x=${a.b.c},y=${a.b.d}", true, + new DummyResolver("x.", "", null)) + runFallbackTest( + "x=${a.b.c},y=\"e.f\"", + "x=${a.b.c},y=${d.e.f}", true, + new DummyResolver("d.", "", null)) + runFallbackTest( + "w=\"Y.c.d\",x=${a},y=\"X.b\",z=\"Y.c\"", + "x=${a},y=${a.b},z=${a.b.c},w=${a.b.c.d}", true, + new DummyResolver("a.b.", "Y.", null), + new DummyResolver("a.", "X.", null)) + + runFallbackTest("x=${a.b.c}", "x=${a.b.c}", true, new DummyResolver("x.", "", null)) + val e = intercept[ConfigException.UnresolvedSubstitution] { + runFallbackTest("x=${a.b.c}", "x=${a.b.c}", false, new DummyResolver("x.", "", null)) + } + assertTrue(e.getMessage.contains("${a.b.c}")) + } + }