From b0eb502a8d5f67658b3ec8c84985a70a2dc7045f Mon Sep 17 00:00:00 2001 From: Rebeca Gallardo Date: Fri, 4 Oct 2024 10:12:52 -0700 Subject: [PATCH] Go back to the old behavior of returning null on ParseExceptions We discovered that too many consumers depend on the old behavior that avoided throwing exceptions from configuration reading calls. Trying to adapt code paths that put these calls in the middle of critical request-servicing code is too onerous. This version DOES fix the old bug that caused properties with defaults (from the Property.orElse() method) to fail for this "invalid setting" case. They will now return the default value. We are also retaining the noisy logging of parsing errors, to assist users with detection and fixing. --- .../api/annotations/Configuration.java | 27 ++++--- .../netflix/archaius/ConfigProxyFactory.java | 79 +++++++++++++------ .../archaius/DefaultPropertyFactory.java | 62 ++++++++------- .../netflix/archaius/ProxyFactoryTest.java | 50 ++++++------ .../archaius/property/PropertyTest.java | 75 ++++++++++-------- 5 files changed, 177 insertions(+), 116 deletions(-) diff --git a/archaius2-api/src/main/java/com/netflix/archaius/api/annotations/Configuration.java b/archaius2-api/src/main/java/com/netflix/archaius/api/annotations/Configuration.java index 24a5e83db..bd2e225b7 100644 --- a/archaius2-api/src/main/java/com/netflix/archaius/api/annotations/Configuration.java +++ b/archaius2-api/src/main/java/com/netflix/archaius/api/annotations/Configuration.java @@ -22,8 +22,17 @@ import java.lang.annotation.Target; /** - * Marks a field as a configuration item. Governator will auto-assign the value based - * on the {@link #value()} of the annotation via the set {@link ConfigurationProvider}. + * When applied to an interface, marks it as a candidate for binding via a ConfigProxyFactory (from the archaius2-core + * module). For this case, the only relevant attributes are {@link #prefix()}, which sets a shared prefix for all the + * properties bound to the interface's methods, and {@link #immutable()}, which when set to true creates a static proxy + * that always returns the config values as they were at the moment that the proxy object is created. + *

+ * Note that an interface can be bound via the ConfigProxyFactory even if it does NOT have this annotation. + *

+ * When applied to a field, marks it as a configuration item, to be injected with the value of the specified property + * key. This usage is deprecated in favor of using your DI-framework options for injecting configuration values. + * @see PropertyName + * @see DefaultValue */ @Documented @Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @@ -31,37 +40,37 @@ public @interface Configuration { /** - * @return name/key of the config to assign + * name/key of the config to assign */ String prefix() default ""; /** - * @return field names to use for replacement + * field names to use for replacement */ String[] params() default {}; /** - * @return user displayable description of this configuration + * user displayable description of this configuration */ String documentation() default ""; /** - * @return true to allow mapping configuration to fields + * true to allow mapping configuration to fields */ boolean allowFields() default false; /** - * @return true to allow mapping configuration to setters + * true to allow mapping configuration to setters */ boolean allowSetters() default true; /** - * @return Method to call after configuration is bound + * Method to call after configuration is bound */ String postConfigure() default ""; /** - * @return If true then properties cannot change once set otherwise methods will be + * If true then properties cannot change once set otherwise methods will be * bound to dynamic properties via PropertyFactory. */ boolean immutable() default false; diff --git a/archaius2-core/src/main/java/com/netflix/archaius/ConfigProxyFactory.java b/archaius2-core/src/main/java/com/netflix/archaius/ConfigProxyFactory.java index 674bb6191..e99312847 100644 --- a/archaius2-core/src/main/java/com/netflix/archaius/ConfigProxyFactory.java +++ b/archaius2-core/src/main/java/com/netflix/archaius/ConfigProxyFactory.java @@ -42,7 +42,7 @@ /** * Factory for binding a configuration interface to properties in a {@link PropertyFactory} * instance. Getter methods on the interface are mapped by naming convention - * by the property name may be overridden using the @PropertyName annotation. + * by the property name or may be overridden using the @{@link PropertyName} annotation. *

* For example, *

@@ -52,11 +52,14 @@
  *    int getTimeout();     // maps to "foo.timeout"
  *
  *    String getName();     // maps to "foo.name"
+ *
+ *    @PropertyName(name="bar")
+ *    String getSomeOtherName();  // maps to "foo.bar"
  * }
  * }
  * 
* - * Default values may be set by adding a {@literal @}DefaultValue with a default value string. Note + * Default values may be set by adding a {@literal @}{@link DefaultValue} with a default value string. Note * that the default value type is a string to allow for interpolation. Alternatively, methods can * provide a default method implementation. Note that {@literal @}DefaultValue cannot be added to a default * method as it would introduce ambiguity as to which mechanism wins. @@ -82,7 +85,7 @@ * } * * - * To override the prefix in {@literal @}Configuration or provide a prefix when there is no + * To override the prefix in {@literal @}{@link Configuration} or provide a prefix when there is no * {@literal @}Configuration annotation simply pass in a prefix in the call to newProxy. * *
@@ -92,7 +95,10 @@
  * 
* * By default, all properties are dynamic and can therefore change from call to call. To make the - * configuration static set the immutable attributes of @Configuration to true. + * configuration static set {@link Configuration#immutable()} to true. Creation of an immutable configuration + * will fail if the interface contains parametrized methods or methods that return primitive types and do not have a + * value set at the moment of creation, from either the underlying config, a {@link DefaultValue} annotation, or a + * default method implementation. *

* Note that an application should normally have just one instance of ConfigProxyFactory * and PropertyFactory since PropertyFactory caches {@link com.netflix.archaius.api.Property} objects. @@ -245,7 +251,8 @@ T newProxy(final Class type, final String initialPrefix, boolean immutabl if (immutable) { // Cache the current value of the property and always return that. - // Note that this will fail for parameterized properties! + // Note that this will fail for parameterized properties and for primitive-valued methods + // with no value set! Object value = methodInvokerHolder.invoker.invoke(new Object[]{}); invokers.put(method, (args) -> value); } else { @@ -296,7 +303,7 @@ private String derivePrefix(Configuration annot, String prefix) { } @SuppressWarnings({"unchecked", "rawtypes"}) - private MethodInvokerHolder buildInvokerForMethod(Class type, String prefix, Method m, T proxyObject, boolean immutable) { + private MethodInvokerHolder buildInvokerForMethod(Class proxyObjectType, String prefix, Method m, T proxyObject, boolean immutable) { try { final Class returnType = m.getReturnType(); @@ -304,16 +311,8 @@ private MethodInvokerHolder buildInvokerForMethod(Class type, String pref final String propName = getPropertyName(prefix, m, nameAnnot); // A supplier for the value to be returned when the method's associated property is not set - final Function defaultValueSupplier; - - if (m.getAnnotation(DefaultValue.class) != null) { - defaultValueSupplier = createAnnotatedMethodSupplier(m, m.getGenericReturnType(), config, decoder); - } else if (m.isDefault()) { - defaultValueSupplier = createDefaultMethodSupplier(m, type, proxyObject); - } else { - // No default specified in proxied interface. Return "empty" for collection types, null for any other type. - defaultValueSupplier = knownCollections.getOrDefault(returnType, (ignored) -> null); - } + // The proper parametrized type for this would be Function, but we can't say that in Java. + final Function defaultValueSupplier = defaultValueSupplierForMethod(proxyObjectType, m, returnType, proxyObject, propName); // This object encapsulates the way to get the value for the current property. final PropertyValueGetter propertyValueGetter; @@ -354,6 +353,42 @@ private MethodInvokerHolder buildInvokerForMethod(Class type, String pref } } + /** + * Build a supplier for the default value to be returned when the underlying property for a method is not set. + * Because of the way {@link Property} works, this will ALSO be called if the underlying property is set to null + * OR if it's set to a "bad" value that can't be decoded to the method's return type. + **/ + private Function defaultValueSupplierForMethod(Class proxyObjectType, Method m, Type returnType, PT proxyObject, String propName) { + if (m.getAnnotation(DefaultValue.class) != null) { + // The method has a @DefaultValue annotation. Decode the string from there and return that. + return createAnnotatedMethodSupplier(m, m.getGenericReturnType(), config, decoder); + } + + if (m.isDefault()) { + // The method has a default implementation in the interface. Obtain the default value by calling that implementation. + return createDefaultMethodSupplier(m, proxyObjectType, proxyObject); + } + + // No default value available. + // For collections, return an empty + if (knownCollections.containsKey(returnType)) { + return knownCollections.get(returnType); + } + + // For primitive return types, our historical behavior of returning a null causes an NPE with no message and an + // obscure trace. Instead of that we now use a fake supplier that will still throw the NPE, but adds a message to it. + if (returnType instanceof Class && ((Class) returnType).isPrimitive()) { + return (ignored) -> { + String msg = String.format("Property '%s' is not set or has an invalid value and method %s.%s does not define a default value", + propName, proxyObjectType.getName(), m.getName()); + throw new NullPointerException(msg); + }; + } + + // For any other return type return nulls. + return (ignored) -> null; + } + /** * Compute the name of the property that will be returned by this method. */ @@ -394,7 +429,7 @@ private static Function memoize(T value) { } /** A supplier that calls a default method in the proxied interface and returns its output */ - private static Function createDefaultMethodSupplier(Method method, Class type, T proxyObject) { + private static Function createDefaultMethodSupplier(Method method, Class proxyObjectType, T proxyObject) { final MethodHandle methodHandle; try { @@ -402,21 +437,21 @@ private static Function createDefaultMethodSupplier(Method meth Constructor constructor = MethodHandles.Lookup.class .getDeclaredConstructor(Class.class, int.class); constructor.setAccessible(true); - methodHandle = constructor.newInstance(type, MethodHandles.Lookup.PRIVATE) - .unreflectSpecial(method, type) + methodHandle = constructor.newInstance(proxyObjectType, MethodHandles.Lookup.PRIVATE) + .unreflectSpecial(method, proxyObjectType) .bindTo(proxyObject); } else { // Java 9 onwards methodHandle = MethodHandles.lookup() - .findSpecial(type, + .findSpecial(proxyObjectType, method.getName(), MethodType.methodType(method.getReturnType(), method.getParameterTypes()), - type) + proxyObjectType) .bindTo(proxyObject); } } catch (ReflectiveOperationException e) { - throw new RuntimeException("Failed to create temporary object for " + type.getName(), e); + throw new RuntimeException("Failed to create temporary object for " + proxyObjectType.getName(), e); } return (args) -> { diff --git a/archaius2-core/src/main/java/com/netflix/archaius/DefaultPropertyFactory.java b/archaius2-core/src/main/java/com/netflix/archaius/DefaultPropertyFactory.java index 115d7c2a6..5bc841d22 100644 --- a/archaius2-core/src/main/java/com/netflix/archaius/DefaultPropertyFactory.java +++ b/archaius2-core/src/main/java/com/netflix/archaius/DefaultPropertyFactory.java @@ -186,31 +186,33 @@ public T get() { // The tricky edge case is if another update came in between the check above to get the version and // the call to the supplier. In that case we'll tag the updated value with an old version number. That's fine, // since the next call to get() will see the old version and try again. + CachedValue newValue; try { // Get the new value from the supplier. This call could fail. - CachedValue newValue = new CachedValue<>(supplier.get(), currentMasterVersion); - - /* - * We successfully got the new value, so now we update the cache. We use an atomic CAS operation to guard - * from edge cases where another thread could have updated to a higher version than we have, in a flow like this: - * Assume currentVersion started at 1., property cache is set to 1 too. - * 1. Upstream update bumps version to 2. - * 2. Thread A reads currentVersion at 2, cachedValue at 1, proceeds to start update, gets interrupted and yields the cpu. - * 3. Thread C bumps version to 3, yields the cpu. - * 4. Thread B is scheduled, reads currentVersion at 3, cachedValue still at 1, proceeds to start update. - * 5. Thread B keeps running, updates cache to 3, yields. - * 6. Thread A resumes, tries to write cache with version 2. - */ - CACHED_VALUE_UPDATER.compareAndSet(this, currentCachedValue, newValue); - - return newValue.value; + newValue = new CachedValue<>(supplier.get(), currentMasterVersion); } catch (RuntimeException e) { - // Oh, no, something went wrong while trying to get the new value. Log the error and rethrow the exception - // so our caller knows there's a problem. We leave the cache unchanged. Next caller will try again. - LOG.error("Unable to get current version of property '{}'", keyAndType.key, e); - throw e; + // Oh, no, something went wrong while trying to get the new value. Log the error and return null. + // Upstream users may return that null unchanged or substitute it by a defaultValue. + // We leave the cache unchanged, which means the next caller will try again. + LOG.error("Unable to update value for property '{}'", keyAndType.key, e); + return null; } + + /* + * We successfully got the new value, so now we update the cache. We use an atomic CAS operation to guard + * from edge cases where another thread could have updated to a higher version than we have, in a flow like this: + * Assume currentVersion started at 1., property cache is set to 1 too. + * 1. Upstream update bumps version to 2. + * 2. Thread A reads currentVersion at 2, cachedValue at 1, proceeds to start update, gets interrupted and yields the cpu. + * 3. Thread C bumps version to 3, yields the cpu. + * 4. Thread B is scheduled, reads currentVersion at 3, cachedValue still at 1, proceeds to start update. + * 5. Thread B keeps running, updates cache to 3, yields. + * 6. Thread A resumes, tries to write cache with version 2. + */ + CACHED_VALUE_UPDATER.compareAndSet(this, currentCachedValue, newValue); + + return newValue.value; } @Override @@ -280,7 +282,7 @@ public synchronized void removeListener(PropertyListener listener) { @Override public Property orElse(T defaultValue) { return new PropertyImpl<>(keyAndType, () -> { - T value = supplier.get(); + T value = this.get(); // Value from the "parent" property return value != null ? value : defaultValue; }); } @@ -288,12 +290,12 @@ public Property orElse(T defaultValue) { @Override public Property orElseGet(String key) { if (!keyAndType.hasType()) { - throw new IllegalStateException("Type information lost due to map() operation. All calls to orElse[Get] must be made prior to calling map"); + throw new IllegalStateException("Type information lost due to map() operation. All calls to orElseGet() must be made prior to calling map"); } KeyAndType keyAndType = this.keyAndType.withKey(key); Property next = DefaultPropertyFactory.this.get(key, keyAndType.type); return new PropertyImpl<>(keyAndType, () -> { - T value = supplier.get(); + T value = this.get(); // Value from the "parent" property return value != null ? value : next.get(); }); } @@ -301,7 +303,7 @@ public Property orElseGet(String key) { @Override public Property map(Function mapper) { return new PropertyImpl<>(keyAndType.discardType(), () -> { - T value = supplier.get(); + T value = this.get(); // Value from the "parent" property if (value != null) { return mapper.apply(value); } else { @@ -464,12 +466,14 @@ public Property asType(Class type, T defaultValue) { public Property asType(Function mapper, String defaultValue) { T typedDefaultValue = applyOrThrow(mapper, defaultValue); return getFromSupplier(propName, null, () -> { - String value = config.getString(propName, null); - if (value != null) { - return applyOrThrow(mapper, value); - } + String stringValue = config.getString(propName, null); - return typedDefaultValue; + try { + return stringValue != null ? applyOrThrow(mapper, stringValue) : typedDefaultValue; + } catch (ParseException pe) { + LOG.error("Error parsing value '{}' for property '{}'", stringValue, propName, pe); + return typedDefaultValue; + } }); } diff --git a/archaius2-core/src/test/java/com/netflix/archaius/ProxyFactoryTest.java b/archaius2-core/src/test/java/com/netflix/archaius/ProxyFactoryTest.java index d650d64bf..bf60b361a 100644 --- a/archaius2-core/src/test/java/com/netflix/archaius/ProxyFactoryTest.java +++ b/archaius2-core/src/test/java/com/netflix/archaius/ProxyFactoryTest.java @@ -9,7 +9,6 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; import java.util.ArrayList; import java.util.Arrays; @@ -34,7 +33,6 @@ import com.netflix.archaius.api.annotations.PropertyName; import com.netflix.archaius.api.config.SettableConfig; import com.netflix.archaius.config.DefaultSettableConfig; -import com.netflix.archaius.exceptions.ParseException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -86,24 +84,29 @@ public void testDefaultValues() { @Test public void testBadValuesInConfig() { config.setProperty("integer", "a"); + config.setProperty("required", "1"); - RootConfig a = proxyFactory.newProxy(RootConfig.class); + WithRequiredValue a = proxyFactory.newProxy(WithRequiredValue.class); - // We should be able to *build* the config object, but then get an error when trying to get the value - ParseException pe = assertThrows(ParseException.class, a::getInteger); - assertThat(pe.getMessage(), allOf( - containsString("'integer'"), - containsString("'a'"))); + // A bad config value causes the default to be used + assertEquals(0, a.getInteger()); - // Other values do work + // A missing setting returns the default assertEquals(TestEnum.NONE, a.getEnum()); - // But a subsequent, invalid, change to the configs now results in an error + // An updated setting returns the new value + config.setProperty("enum", "A"); + assertEquals(TestEnum.A, a.getEnum()); + + // A second update, to an invalid value, reverts to returning the default. config.setProperty("enum", "NOTINENUM"); - ParseException pe2 = assertThrows(ParseException.class, a::getEnum); - assertThat(pe2.getMessage(), allOf( - containsString("'enum'"), - containsString("'NOTINENUM'"))); + assertEquals(TestEnum.NONE, a.getEnum()); + + assertEquals(1, a.getRequired()); + config.setProperty("required", "a"); + NullPointerException npe = assertThrows(NullPointerException.class, a::getRequired); + // We should get a message that includes the property name + assertThat(npe.getMessage(), containsString("required")); } @Test @@ -131,7 +134,7 @@ public void testAllPropertiesSet() { config.setProperty("prefix.baseBoolean", true); config.setProperty("prefix.intArray", "0,1,2,3"); - RootConfig a = proxyFactory.newProxy(RootConfig.class, "prefix"); + WithRequiredValue a = proxyFactory.newProxy(WithRequiredValue.class, "prefix"); assertThat(a.getStr(), equalTo("str1")); assertThat(a.getInteger(), equalTo(1)); @@ -145,12 +148,9 @@ public void testAllPropertiesSet() { config.setProperty("prefix.subConfig.str", "str3"); assertThat(a.getSubConfig().str(), equalTo("str3")); - try { - a.getRequiredValue(); - fail("should have failed with no value for requiredValue"); - } - catch (Exception expected) { - } + NullPointerException npe = assertThrows(NullPointerException.class, a::getRequired); + // We should get a message that includes the property name + assertThat(npe.getMessage(), containsString("prefix.required")); } @Test @@ -494,13 +494,17 @@ public interface RootConfig extends BaseConfig { @DefaultValue("") Integer[] getIntArray(); - int getRequiredValue(); - default long getOtherLongValueWithDefault() { return 43L; } } + @SuppressWarnings("UnusedReturnValue") + public interface WithRequiredValue extends RootConfig { + // A primitive-valued property will throw an exception if it's not set. + int getRequired(); + } + public interface SubConfig { @DefaultValue("default") String str(); diff --git a/archaius2-core/src/test/java/com/netflix/archaius/property/PropertyTest.java b/archaius2-core/src/test/java/com/netflix/archaius/property/PropertyTest.java index 8bb5fe60c..5b14d3377 100644 --- a/archaius2-core/src/test/java/com/netflix/archaius/property/PropertyTest.java +++ b/archaius2-core/src/test/java/com/netflix/archaius/property/PropertyTest.java @@ -33,7 +33,6 @@ import java.util.function.IntConsumer; import com.netflix.archaius.api.PropertyContainer; -import com.netflix.archaius.exceptions.ParseException; import org.junit.jupiter.api.BeforeEach; import org.mockito.Mockito; @@ -47,9 +46,6 @@ import org.junit.jupiter.api.Test; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.allOf; -import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -441,29 +437,43 @@ public void chainedPropertyNotification() { @Test public void testBadValue() { - config.setProperty("first", "bad"); + Property prop = factory.get("first", Integer.class); - Property prop = factory - .get("first", Integer.class); + // No value, property returns null + assertNull(prop.get()); - ParseException pe = assertThrows(ParseException.class, prop::get); - assertThat(pe.getMessage(), allOf( - containsString("'first'"), - containsString("'bad'"))); + // Set to 1 + config.setProperty("first", "1"); + assertEquals(1, prop.get().intValue()); + + // Set to bad value, property reverts to returning null + config.setProperty("first", "bad"); + assertNull(prop.get()); + + // Set to good value again + config.setProperty("first", "2"); + assertEquals(2, prop.get().intValue()); } + @Test public void chainedPropertyBadValue() { - config.setProperty("first", "bad"); + Property prop = factory.get("first", Integer.class).orElse(3); - Property prop = factory - .get("first", Integer.class) - .orElse(3); + // No value, property returns default + assertEquals(3, prop.get()); + + // Set to 1 + config.setProperty("first", "1"); + assertEquals(1, prop.get().intValue()); - ParseException pe = assertThrows(ParseException.class, prop::get); - assertThat(pe.getMessage(), allOf( - containsString("'first'"), - containsString("'bad'"))); + // Set to bad value, property reverts to returning default + config.setProperty("first", "bad"); + assertEquals(3, prop.get()); + + // Set to good value again + config.setProperty("first", "2"); + assertEquals(2, prop.get().intValue()); } @Test @@ -474,12 +484,10 @@ public void testSubscriptionsBadValue() { .orElse(3); Property stringProperty = factory.get("first", String.class); - Consumer integerConsumer = unexpected -> fail("Consumer should not be called. Received argument: " + unexpected); + AtomicInteger integerConsumer = new AtomicInteger(); AtomicReference stringConsumer = new AtomicReference<>(); - // Order is important here! We want the string subscription to be called *after* trying to call the integer - // subscription, which should fail because "a" can not be decoded as an integer - integerProperty.subscribe(integerConsumer); + integerProperty.subscribe(integerConsumer::set); stringProperty.subscribe(stringConsumer::set); // Initial value for the property is 2 @@ -489,7 +497,8 @@ public void testSubscriptionsBadValue() { // Set it to something that's not an integer config.setProperty("first", "a"); - // The integer consumer is never called + // The integer consumer is called with the default value + assertEquals(3, integerConsumer.get()); // The string consumer *is* called with the new value assertEquals("a", stringConsumer.get()); } @@ -520,7 +529,8 @@ public void testSubscriptionsNullValue() { // Set it to something that's not an integer config.setProperty("first", null); - // The integer consumer is never called + // The primitive int consumer is never called. The subscription machinery will get an NPE internally when + // it tries to cast the null to a primitive int. Nevertheless, the rest of the consumers should still be called. // The string consumer *is* called with the new value assertNull(stringConsumer.get()); // ... the boxed integer consumer also gets the new value @@ -545,7 +555,9 @@ public void testSubscriptionsConsumerFails() { AtomicReference stringConsumer = new AtomicReference<>(); // Order is important here! We want the string subscription to be called *after* trying to call the integer - // subscription, which should fail because "a" can not be decoded as an integer + // subscription, which should fail because "a" can not be decoded as an integer. + // This test relies on the fact that the property factory calls listeners in the order they were added. + // THAT IS NOT PART OF THE PUBLIC CONTRACT! integerProperty.subscribe(faultyConsumer); stringProperty.subscribe(stringConsumer::set); @@ -720,14 +732,11 @@ public void testPropertyContainer() { config.setProperty("foo", 3); assertEquals(3, prop.get().intValue()); - // Set to an invalid value + // Setting to an invalid value should cause the default value to be used config.setProperty("foo", "notAnInt"); - ParseException pe = assertThrows(ParseException.class, prop::get); - assertThat(pe.getMessage(), allOf(containsString("foo"), containsString("notAnInt"))); + assertEquals(1, prop.get()); - // Using the custom parser feature should still return a ParseException. Older versions of the - // code would silently return the default value instead. - ParseException pe2 = assertThrows(ParseException.class, () -> container.asType(Integer::parseInt, "1").get()); - assertThat(pe2.getMessage(), allOf(containsString("foo"), containsString("notAnInt"))); + // Using the custom parser feature should return the default value when the value is invalid + assertEquals(1, container.asType(Integer::parseInt, "1").get()); } }