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 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[] 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 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 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 clazz) { + substitutors.put(DEFAULT_SUBSTITUTOR_NAME, new DefaultSubstitutor(values)); + addCustomSubstitutors(clazz); + } + + private void addCustomSubstitutors(Class 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 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()); + } + +}