diff --git a/owner/src/main/java/org/aeonbits/owner/Config.java b/owner/src/main/java/org/aeonbits/owner/Config.java
index a3d3889b..c9bb045e 100644
--- a/owner/src/main/java/org/aeonbits/owner/Config.java
+++ b/owner/src/main/java/org/aeonbits/owner/Config.java
@@ -9,6 +9,15 @@
package org.aeonbits.owner;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.aeonbits.owner.Config.HotReloadType.SYNC;
+import static org.aeonbits.owner.Config.LoadType.FIRST;
+import static org.aeonbits.owner.Util.ignore;
+import static org.aeonbits.owner.Util.reverse;
+
import java.io.IOException;
import java.io.Serializable;
import java.lang.annotation.Documented;
@@ -19,15 +28,6 @@
import java.util.Properties;
import java.util.concurrent.TimeUnit;
-import static java.lang.annotation.ElementType.METHOD;
-import static java.lang.annotation.ElementType.TYPE;
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.aeonbits.owner.Config.HotReloadType.SYNC;
-import static org.aeonbits.owner.Config.LoadType.FIRST;
-import static org.aeonbits.owner.Util.ignore;
-import static org.aeonbits.owner.Util.reverse;
-
/**
* Marker interface that must be implemented by all Config sub-interfaces.
*
@@ -329,4 +329,16 @@ enum DisableableFeature {
Class extends Converter> value();
}
+ /**
+ * Registers {@link Substitutor} classes, with given names, to allow the user to define custom
+ * substitutor logic when expanding variables in property strings.
+ */
+ @Retention(RUNTIME)
+ @Target(TYPE)
+ @Documented
+ @interface SubstitutorClasses {
+ String[] names();
+ Class extends Substitutor>[] classes();
+ }
+
}
diff --git a/owner/src/main/java/org/aeonbits/owner/DefaultFactory.java b/owner/src/main/java/org/aeonbits/owner/DefaultFactory.java
index ecc48347..e3b44cc4 100644
--- a/owner/src/main/java/org/aeonbits/owner/DefaultFactory.java
+++ b/owner/src/main/java/org/aeonbits/owner/DefaultFactory.java
@@ -8,13 +8,13 @@
package org.aeonbits.owner;
-import org.aeonbits.owner.loaders.Loader;
+import static java.lang.reflect.Proxy.newProxyInstance;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ScheduledExecutorService;
-import static java.lang.reflect.Proxy.newProxyInstance;
+import org.aeonbits.owner.loaders.Loader;
/**
* Default implementation for {@link Factory}.
@@ -39,7 +39,7 @@ public T create(Class extends T> clazz, Map, ?>... import
VariablesExpander expander = new VariablesExpander(props);
PropertiesManager manager = new PropertiesManager(clazz, new Properties(), scheduler, expander, loadersManager,
imports);
- PropertiesInvocationHandler handler = new PropertiesInvocationHandler(manager);
+ PropertiesInvocationHandler handler = new PropertiesInvocationHandler(manager, clazz);
T proxy = (T) newProxyInstance(clazz.getClassLoader(), interfaces, handler);
handler.setProxy(proxy);
return proxy;
diff --git a/owner/src/main/java/org/aeonbits/owner/DefaultSubstitutor.java b/owner/src/main/java/org/aeonbits/owner/DefaultSubstitutor.java
new file mode 100644
index 00000000..fa00c494
--- /dev/null
+++ b/owner/src/main/java/org/aeonbits/owner/DefaultSubstitutor.java
@@ -0,0 +1,24 @@
+package org.aeonbits.owner;
+
+import java.io.Serializable;
+import java.util.Properties;
+
+
+final class DefaultSubstitutor implements Substitutor, Serializable {
+
+ private final Properties variables;
+
+ DefaultSubstitutor(Properties props) {
+ variables = props;
+ }
+
+ public String replace(String strToReplace) {
+ return variables.getProperty(strToReplace);
+ }
+
+ @Override
+ public String toString() {
+ return variables.toString();
+ }
+
+}
diff --git a/owner/src/main/java/org/aeonbits/owner/PropertiesInvocationHandler.java b/owner/src/main/java/org/aeonbits/owner/PropertiesInvocationHandler.java
index fc05aad8..b6c95187 100644
--- a/owner/src/main/java/org/aeonbits/owner/PropertiesInvocationHandler.java
+++ b/owner/src/main/java/org/aeonbits/owner/PropertiesInvocationHandler.java
@@ -8,6 +8,14 @@
package org.aeonbits.owner;
+import static org.aeonbits.owner.Config.DisableableFeature.PARAMETER_FORMATTING;
+import static org.aeonbits.owner.Config.DisableableFeature.VARIABLE_EXPANSION;
+import static org.aeonbits.owner.Converters.convert;
+import static org.aeonbits.owner.PropertiesMapper.key;
+import static org.aeonbits.owner.Util.isFeatureDisabled;
+import static org.aeonbits.owner.util.Reflection.invokeDefaultMethod;
+import static org.aeonbits.owner.util.Reflection.isDefault;
+
import java.io.Serializable;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
@@ -16,14 +24,7 @@
import java.util.LinkedList;
import java.util.List;
-import static org.aeonbits.owner.Config.DisableableFeature.PARAMETER_FORMATTING;
-import static org.aeonbits.owner.Config.DisableableFeature.VARIABLE_EXPANSION;
-import static org.aeonbits.owner.Converters.convert;
-import static org.aeonbits.owner.PropertiesManager.Delegate;
-import static org.aeonbits.owner.PropertiesMapper.key;
-import static org.aeonbits.owner.Util.isFeatureDisabled;
-import static org.aeonbits.owner.util.Reflection.invokeDefaultMethod;
-import static org.aeonbits.owner.util.Reflection.isDefault;
+import org.aeonbits.owner.PropertiesManager.Delegate;
/**
* This {@link InvocationHandler} receives method calls from the delegate instantiated by {@link ConfigFactory} and maps
@@ -44,9 +45,9 @@ class PropertiesInvocationHandler implements InvocationHandler, Serializable {
private final StrSubstitutor substitutor;
final PropertiesManager propertiesManager;
- PropertiesInvocationHandler(PropertiesManager manager) {
+ PropertiesInvocationHandler(PropertiesManager manager, Class extends Config> clazz) {
this.propertiesManager = manager;
- this.substitutor = new StrSubstitutor(manager.load());
+ this.substitutor = new StrSubstitutor(manager.load(), clazz);
}
public Object invoke(Object proxy, Method invokedMethod, Object... args) throws Throwable {
diff --git a/owner/src/main/java/org/aeonbits/owner/StrSubstitutor.java b/owner/src/main/java/org/aeonbits/owner/StrSubstitutor.java
index 329feedd..412bfd93 100644
--- a/owner/src/main/java/org/aeonbits/owner/StrSubstitutor.java
+++ b/owner/src/main/java/org/aeonbits/owner/StrSubstitutor.java
@@ -8,13 +8,18 @@
package org.aeonbits.owner;
+import static java.util.regex.Pattern.compile;
+import static org.aeonbits.owner.Util.fixBackslashForRegex;
+import static org.aeonbits.owner.Util.unsupported;
+
import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import static java.util.regex.Pattern.compile;
-import static org.aeonbits.owner.Util.fixBackslashForRegex;
+import org.aeonbits.owner.Config.SubstitutorClasses;
/**
*
@@ -48,8 +53,10 @@
*/
class StrSubstitutor implements Serializable {
- private final Properties values;
private static final Pattern PATTERN = compile("\\$\\{(.+?)\\}");
+ private static final String DEFAULT_SUBSTITUTOR_NAME = "";
+ private static final int TO_SIZE_2 = 2;
+ private final Map substitutors = new HashMap();
/**
* Creates a new instance and initializes it. Uses defaults for variable prefix and suffix and the escaping
@@ -58,7 +65,41 @@ class StrSubstitutor implements Serializable {
* @param values the variables' values, may be null
*/
StrSubstitutor(Properties values) {
- this.values = values;
+ substitutors.put(DEFAULT_SUBSTITUTOR_NAME, new DefaultSubstitutor(values));
+ }
+
+ StrSubstitutor(Properties values, Class extends Config> clazz) {
+ substitutors.put(DEFAULT_SUBSTITUTOR_NAME, new DefaultSubstitutor(values));
+ addCustomSubstitutors(clazz);
+ }
+
+ private void addCustomSubstitutors(Class extends Config> configClazz) {
+ SubstitutorClasses annotation = configClazz.getAnnotation(SubstitutorClasses.class);
+ if (annotation != null) {
+ checkSameSize(annotation);
+ for (int i = 0; i < annotation.names().length; i++) {
+ String name = annotation.names()[i];
+ Class extends Substitutor> clazz = annotation.classes()[i];
+ try {
+ substitutors.put(name, clazz.newInstance());
+ } catch (Exception e) {
+ throw unsupported(e,
+ "Substitutor class '%s' cannot be instantiated; see the cause below in the stack trace",
+ clazz.getCanonicalName());
+ }
+ }
+ }
+
+ }
+
+ private void checkSameSize(SubstitutorClasses annotation) {
+ int numOfNames = annotation.names().length;
+ int numOfClasses = annotation.classes().length;
+ if (numOfNames != numOfClasses) {
+ throw unsupported(
+ "Mismatch in number of names (%s) and classes (%s) in annotation %s",
+ numOfNames, numOfClasses, annotation);
+ }
}
/**
@@ -74,8 +115,7 @@ String replace(String source) {
Matcher m = PATTERN.matcher(source);
StringBuffer sb = new StringBuffer();
while (m.find()) {
- String var = m.group(1);
- String value = values.getProperty(var);
+ String value = replaceValueUsingSubstitutors(m.group(1));
String replacement = (value != null) ? replace(value) : "";
m.appendReplacement(sb, fixBackslashForRegex(replacement));
}
@@ -83,4 +123,21 @@ String replace(String source) {
return sb.toString();
}
+ private String replaceValueUsingSubstitutors(final String strToReplace) {
+ String name = DEFAULT_SUBSTITUTOR_NAME;
+ String value = strToReplace;
+ if (strToReplace.contains(":")) {
+ String[] pair = strToReplace.split(":", TO_SIZE_2);
+ name = pair[0];
+ value = pair[1];
+ }
+ if (name.equals(DEFAULT_SUBSTITUTOR_NAME)) {
+ return substitutors.get(DEFAULT_SUBSTITUTOR_NAME).replace(strToReplace);
+ }
+ if (substitutors.containsKey(name)) {
+ return substitutors.get(name).replace(value);
+ }
+ return substitutors.get(DEFAULT_SUBSTITUTOR_NAME).replace(strToReplace);
+ }
+
}
diff --git a/owner/src/main/java/org/aeonbits/owner/Substitutor.java b/owner/src/main/java/org/aeonbits/owner/Substitutor.java
new file mode 100644
index 00000000..a0816b21
--- /dev/null
+++ b/owner/src/main/java/org/aeonbits/owner/Substitutor.java
@@ -0,0 +1,21 @@
+package org.aeonbits.owner;
+
+import org.aeonbits.owner.Config.SubstitutorClasses;
+
+/**
+ * Substitutor interface specifies how to provide a custom substitutor mechanism when replacing
+ * variables in strings. Substitutors are registered by name and implementation class using
+ * {@link SubstitutorClasses} annotation. For example this string "${name:some-value}" the
+ * "some-value" part will be sent to the Substitutor implementation registered
+ * with the name "name".
+ */
+public interface Substitutor {
+
+ /**
+ * Replaces the given {@code strToReplace} to something custom.
+ *
+ * @param strToReplace the value to replace
+ * @return the replacement value
+ */
+ String replace(String strToReplace);
+}
diff --git a/owner/src/test/java/org/aeonbits/owner/PropertiesInvocationHandlerTest.java b/owner/src/test/java/org/aeonbits/owner/PropertiesInvocationHandlerTest.java
index 6c523a82..d63f4bba 100644
--- a/owner/src/test/java/org/aeonbits/owner/PropertiesInvocationHandlerTest.java
+++ b/owner/src/test/java/org/aeonbits/owner/PropertiesInvocationHandlerTest.java
@@ -8,20 +8,20 @@
package org.aeonbits.owner;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.Spy;
-import org.mockito.runners.MockitoJUnitRunner;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.verify;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.util.Properties;
import java.util.concurrent.ScheduledExecutorService;
-import static org.mockito.Matchers.eq;
-import static org.mockito.Mockito.verify;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.runners.MockitoJUnitRunner;
/**
* @author Luigi R. Viggiano
@@ -47,7 +47,7 @@ interface Dummy extends Config {}
@Before
public void before() {
PropertiesManager loader = new PropertiesManager(Dummy.class, properties, scheduler, expander, loaders);
- handler = new PropertiesInvocationHandler(loader);
+ handler = new PropertiesInvocationHandler(loader, Dummy.class);
}
@Test
diff --git a/owner/src/test/java/org/aeonbits/owner/variableexpansion/SubstitutorTest.java b/owner/src/test/java/org/aeonbits/owner/variableexpansion/SubstitutorTest.java
new file mode 100644
index 00000000..21a62e84
--- /dev/null
+++ b/owner/src/test/java/org/aeonbits/owner/variableexpansion/SubstitutorTest.java
@@ -0,0 +1,154 @@
+package org.aeonbits.owner.variableexpansion;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Properties;
+import java.util.Random;
+
+import org.aeonbits.owner.Config;
+import org.aeonbits.owner.ConfigFactory;
+import org.aeonbits.owner.Substitutor;
+import org.junit.Test;
+
+
+public class SubstitutorTest {
+
+ public static class DateSubstitutor implements Substitutor {
+ private Date date = new Date();
+ public String replace(String strToReplace) {
+ return new SimpleDateFormat(strToReplace).format(date);
+ }
+ }
+
+ @Config.SubstitutorClasses(names = "date", classes = DateSubstitutor.class)
+ public interface UseOfCustomeDateSubstitutorConfig extends Config {
+ @DefaultValue("Today is: ${date:yyyyMMdd}")
+ String today();
+ }
+
+ @Test
+ public void canSubstituteWhenSingleSubstituorClassProvided() {
+ UseOfCustomeDateSubstitutorConfig cfg = ConfigFactory.create(UseOfCustomeDateSubstitutorConfig.class);
+ assertTrue(cfg.today().matches("Today is: \\d{8}"));
+ }
+
+ @Test
+ public void shouldOnlyInstantiateSubstitutorClassOnce() {
+ UseOfCustomeDateSubstitutorConfig cfg = ConfigFactory.create(UseOfCustomeDateSubstitutorConfig.class);
+ String today = cfg.today();
+ assertTrue(today.equals(cfg.today()));
+ }
+
+ public static class RandomIntSubstitutor implements Substitutor {
+ Random random = new Random();
+ public String replace(String strToReplace) {
+ return String.valueOf(random.nextInt(Integer.parseInt(strToReplace)));
+ }
+ }
+
+ @Config.SubstitutorClasses(
+ names = { "random", "date" },
+ classes = { RandomIntSubstitutor.class, DateSubstitutor.class })
+ public interface UseOfMultipleCustomeSubstitutorConfig extends Config {
+ @DefaultValue("A random '${random:100}' integer given this year ${date:yyyy}")
+ String random();
+ }
+
+ @Test
+ public void canSubstituteWhenMultipleSubstituorClassesProvided() {
+ UseOfMultipleCustomeSubstitutorConfig cfg = ConfigFactory.create(UseOfMultipleCustomeSubstitutorConfig.class);
+ String expectedPattern = "A random '\\d{1,2}' integer given this year \\d{4}";
+ assertTrue(cfg.random().matches(expectedPattern));
+ }
+
+ @Config.SubstitutorClasses(
+ names = { "random", "date" },
+ classes = { RandomIntSubstitutor.class })
+ public interface MismatchInNumberOfNamesAndClassesConfig extends Config {
+ String key();
+ }
+
+ @Test(expected = UnsupportedOperationException.class)
+ public void shouldThrowWhenMismatchInNumberOfNamesAndClasses() throws Exception {
+ ConfigFactory.create(MismatchInNumberOfNamesAndClassesConfig.class);
+ }
+
+ static class NotInstantiatableSubstitutor implements Substitutor {
+ public String replace(String strToReplace) {
+ return "";
+ }
+ }
+
+ @Config.SubstitutorClasses(
+ names = "uninstantiatable", classes = NotInstantiatableSubstitutor.class)
+ public interface WithUninstantiatableSubstitutorConfig extends Config {
+ String key();
+ }
+
+ @Test(expected = UnsupportedOperationException.class)
+ public void shouldThrowWhenSubstitutorNotInstantiatable() throws Exception {
+ ConfigFactory.create(WithUninstantiatableSubstitutorConfig.class);
+ }
+
+ public interface WithNoSubstitutorConfig extends Config {
+ @DefaultValue("Substitutor ${a:value} is missing")
+ String key();
+ }
+
+ @Test
+ public void canSubstituteToEmptyStringWhenSubstitutorIsMissing() throws Exception {
+ WithNoSubstitutorConfig cfg = ConfigFactory.create(WithNoSubstitutorConfig.class);
+ assertEquals("Substitutor is missing", cfg.key());
+ }
+
+ public static class HandlingEmptyValueSubstitutor implements Substitutor {
+ public String replace(String strToReplace) {
+ assertTrue(strToReplace.isEmpty());
+ return "default-value";
+ }
+ }
+
+ @Config.SubstitutorClasses(
+ names = "a", classes = HandlingEmptyValueSubstitutor.class)
+ public interface WithEmptyValueSubstitutorConfig extends Config {
+ @DefaultValue("Empty ${a:} value")
+ String key();
+ }
+
+ @Test
+ public void canPassEmptyStringToSubstitutor() throws Exception {
+ WithEmptyValueSubstitutorConfig cfg = ConfigFactory.create(WithEmptyValueSubstitutorConfig.class);
+ assertEquals("Empty default-value value", cfg.key());
+ }
+
+ public interface WithNameStartingByColonConfig extends Config {
+ @DefaultValue("${:a}")
+ String key();
+ }
+
+ @Test
+ public void canUseDefaultSubstitutorWhenNameStartsWithColon() throws Exception {
+ Properties p = new Properties();
+ p.setProperty(":a", "A");
+ WithNameStartingByColonConfig cfg = ConfigFactory.create(WithNameStartingByColonConfig.class, p);
+ assertEquals("A", cfg.key());
+ }
+
+ public interface WithValueContainingColonConfig extends Config {
+
+ @DefaultValue("${na:me}")
+ String key();
+ }
+
+ @Test
+ public void canUseDefaultSubstitutorWhenNameIsNotARegisteredSubstitutor() throws Exception {
+ Properties p = new Properties();
+ p.setProperty("na:me", "NAME");
+ WithValueContainingColonConfig cfg = ConfigFactory.create(WithValueContainingColonConfig.class, p);
+ assertEquals("NAME", cfg.key());
+ }
+
+}