diff --git a/log4j-kit/pom.xml b/log4j-kit/pom.xml index 02db7e75be6..f9470106ab8 100644 --- a/log4j-kit/pom.xml +++ b/log4j-kit/pom.xml @@ -60,6 +60,12 @@ test + + org.junit.jupiter + junit-jupiter-params + test + + org.javassist javassist diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/Log4jProperty.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/Log4jProperty.java new file mode 100644 index 00000000000..58648c2b36f --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/Log4jProperty.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotates a class or parameter that stores Log4j API configuration properties. + *

+ * This annotation is required for root property classes. + *

+ */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD}) +public @interface Log4jProperty { + + /** + * Provides a name for the configuration property. + */ + String name() default ""; + + /** + * Provides the default value of the property. + *

+ * This only applies to scalar values. + *

+ */ + String defaultValue() default ""; +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/PropertyEnvironment.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/PropertyEnvironment.java new file mode 100644 index 00000000000..c46e1ad1fef --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/PropertyEnvironment.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env; + +import java.nio.charset.Charset; +import java.time.Duration; +import org.apache.logging.log4j.kit.env.internal.PropertiesUtilPropertyEnvironment; +import org.jspecify.annotations.Nullable; + +/** + * Represents the main access point to Log4j properties. + *

+ * It provides as typesafe way to access properties stored in multiple {@link PropertySource}s, type conversion + * methods and property aggregation methods (cf. {@link #getProperty(Class)}). + *

+ */ +public interface PropertyEnvironment { + + static PropertyEnvironment getGlobal() { + return PropertiesUtilPropertyEnvironment.INSTANCE; + } + + /** + * Gets the named property as a boolean value. If the property matches the string {@code "true"} (case-insensitive), + * then it is returned as the boolean value {@code true}. Any other non-{@code null} text in the property is + * considered {@code false}. + * + * @param name the name of the property to look up + * @return the boolean value of the property or {@code false} if undefined. + */ + default boolean getBooleanProperty(final String name) { + return getBooleanProperty(name, false); + } + + /** + * Gets the named property as a boolean value. + * + * @param name the name of the property to look up + * @param defaultValue the default value to use if the property is undefined + * @return the boolean value of the property or {@code defaultValue} if undefined. + */ + Boolean getBooleanProperty(String name, Boolean defaultValue); + + /** + * Gets the named property as a Charset value. + * + * @param name the name of the property to look up + * @return the Charset value of the property or {@link Charset#defaultCharset()} if undefined. + */ + @SuppressWarnings("null") + default Charset getCharsetProperty(final String name) { + return getCharsetProperty(name, Charset.defaultCharset()); + } + + /** + * Gets the named property as a Charset value. + * + * @param name the name of the property to look up + * @param defaultValue the default value to use if the property is undefined + * @return the Charset value of the property or {@code defaultValue} if undefined. + */ + Charset getCharsetProperty(String name, Charset defaultValue); + + /** + * Gets the named property as a Class value. + * + * @param name the name of the property to look up + * @param upperBound the upper bound for the class + * @return the Class value of the property or {@code null} if it can not be loaded. + */ + @Nullable Class getClassProperty(final String name, final Class upperBound); + + /** + * Gets the named property as a subclass of {@code upperBound}. + * + * @param name the name of the property to look up + * @param defaultValue the default value to use if the property is undefined + * @param upperBound the upper bound for the class + * @return the Class value of the property or {@code defaultValue} if it can not be loaded. + */ + Class getClassProperty(String name, Class defaultValue, Class upperBound); + + /** + * Gets the named property as {@link Duration}. + * + * @param name The property name. + * @return The value of the String as a Duration or {@link Duration#ZERO} if it was undefined or could not be parsed. + */ + default Duration getDurationProperty(final String name) { + return getDurationProperty(name, Duration.ZERO); + } + + /** + * Gets the named property as {@link Duration}. + * + * @param name The property name. + * @param defaultValue The default value. + * @return The value of the String as a Duration or {@code defaultValue} if it was undefined or could not be parsed. + */ + Duration getDurationProperty(String name, Duration defaultValue); + + /** + * Gets the named property as an integer. + * + * @param name the name of the property to look up + * @return the parsed integer value of the property or {@code 0} if it was undefined or could not be + * parsed. + */ + default int getIntegerProperty(final String name) { + return getIntegerProperty(name, 0); + } + + /** + * Gets the named property as an integer. + * + * @param name the name of the property to look up + * @param defaultValue the default value to use if the property is undefined + * @return the parsed integer value of the property or {@code defaultValue} if it was undefined or could not be + * parsed. + */ + Integer getIntegerProperty(String name, Integer defaultValue); + + /** + * Gets the named property as a long. + * + * @param name the name of the property to look up + * @return the parsed long value of the property or {@code 0} if it was undefined or could not be + * parsed. + */ + default long getLongProperty(final String name) { + return getLongProperty(name, 0L); + } + + /** + * Gets the named property as a long. + * + * @param name the name of the property to look up + * @param defaultValue the default value to use if the property is undefined + * @return the parsed long value of the property or {@code defaultValue} if it was undefined or could not be parsed. + */ + Long getLongProperty(String name, Long defaultValue); + + /** + * Gets the named property as a String. + * + * @param name the name of the property to look up + * @return the String value of the property or {@code null} if undefined. + */ + @Nullable + String getStringProperty(String name); + + /** + * Gets the named property as a String. + * + * @param name the name of the property to look up + * @param defaultValue the default value to use if the property is undefined + * @return the String value of the property or {@code defaultValue} if undefined. + */ + default String getStringProperty(final String name, final String defaultValue) { + final String prop = getStringProperty(name); + return (prop == null) ? defaultValue : prop; + } + + /** + * Binds properties to class {@code T}. + *

+ * The implementation should at least support binding Java records with a single public constructor and enums. + *

+ * @param propertyClass a class annotated by {@link Log4jProperty}. + * @return an instance of T with all JavaBean properties bound. + */ + T getProperty(final Class propertyClass); +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/PropertySource.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/PropertySource.java new file mode 100644 index 00000000000..27f29bb723e --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/PropertySource.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env; + +import org.jspecify.annotations.Nullable; + +/** + * Basic interface to retrieve property values. + *

+ * We can not reuse the property sources from 2.x, since those required some sort of {@code log4j} prefix to be + * included. In 3.x we want to use keys without a prefix. + *

+ */ +public interface PropertySource { + /** + * Provides the priority of the property source. + *

+ * Sources with higher priority override values from sources with lower priority. + *

+ * + * @return priority value + */ + int getPriority(); + + /** + * Gets the named property as a String. + * + * @param name the name of the property to look up + * @return the String value of the property or {@code null} if undefined. + */ + @Nullable + String getProperty(String name); +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/ContextualEnvironmentPropertySource.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/ContextualEnvironmentPropertySource.java new file mode 100644 index 00000000000..54a4cdede6d --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/ContextualEnvironmentPropertySource.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env.internal; + +import java.util.Locale; +import org.apache.logging.log4j.status.StatusLogger; +import org.apache.logging.log4j.util.PropertySource; +import org.jspecify.annotations.Nullable; + +/** + * PropertySource backed by the current environment variables. + *

+ * Should haves a slightly lower priority than global environment variables. + *

+ */ +public class ContextualEnvironmentPropertySource implements PropertySource { + + private static final int DEFAULT_PRIORITY = 0; + + private final String prefix; + private final int priority; + + public ContextualEnvironmentPropertySource(final String contextName) { + this(contextName, DEFAULT_PRIORITY); + } + + public ContextualEnvironmentPropertySource(final String contextName, final int priority) { + this.prefix = "log4j2." + contextName + "."; + this.priority = priority; + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public @Nullable String getProperty(final String key) { + final String actualKey = key.replace('.', '_').toUpperCase(Locale.ROOT); + try { + return System.getenv(prefix + actualKey); + } catch (final SecurityException e) { + StatusLogger.getLogger() + .warn( + "{} lacks permissions to access system property {}.", + getClass().getName(), + actualKey, + e); + } + return null; + } + + @Override + public boolean containsProperty(final String key) { + return getProperty(key) != null; + } +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/ContextualJavaPropsPropertySource.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/ContextualJavaPropsPropertySource.java new file mode 100644 index 00000000000..16edc814b46 --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/ContextualJavaPropsPropertySource.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env.internal; + +import org.apache.logging.log4j.status.StatusLogger; +import org.apache.logging.log4j.util.PropertySource; +import org.jspecify.annotations.Nullable; + +/** + * PropertySource backed by the current system properties. + *

+ * Should have a slightly lower priority than global system properties. + *

+ */ +public class ContextualJavaPropsPropertySource implements PropertySource { + + private static final int DEFAULT_PRIORITY = 100; + + private final String prefix; + private final int priority; + + public ContextualJavaPropsPropertySource(final String contextName) { + this(contextName, DEFAULT_PRIORITY); + } + + public ContextualJavaPropsPropertySource(final String contextName, final int priority) { + this.prefix = "log4j.contexts." + contextName + "."; + this.priority = priority; + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public @Nullable String getProperty(final String key) { + final String actualKey = prefix + key; + try { + return System.getProperty(actualKey); + } catch (final SecurityException e) { + StatusLogger.getLogger() + .warn( + "{} lacks permissions to access system property {}.", + getClass().getName(), + actualKey, + e); + } + return null; + } + + @Override + public boolean containsProperty(final String key) { + return getProperty(key) != null; + } +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/PropertiesUtilPropertyEnvironment.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/PropertiesUtilPropertyEnvironment.java new file mode 100644 index 00000000000..8d432204d01 --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/PropertiesUtilPropertyEnvironment.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env.internal; + +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.kit.env.PropertyEnvironment; +import org.apache.logging.log4j.kit.env.support.BasicPropertyEnvironment; +import org.apache.logging.log4j.status.StatusLogger; +import org.apache.logging.log4j.util.PropertiesUtil; + +/** + * An adapter of the {@link PropertiesUtil} from Log4j API 2.x. + * + * @implNote Since {@link PropertiesUtil} requires all properties to start with {@code log4j2.}, we must add the prefix + * before querying for the property. + */ +public class PropertiesUtilPropertyEnvironment extends BasicPropertyEnvironment { + + private static final String PREFIX = "log4j2."; + public static final PropertyEnvironment INSTANCE = + new PropertiesUtilPropertyEnvironment(PropertiesUtil.getProperties(), StatusLogger.getLogger()); + + private final PropertiesUtil propsUtil; + + public PropertiesUtilPropertyEnvironment(final PropertiesUtil propsUtil, final Logger statusLogger) { + super(statusLogger); + this.propsUtil = propsUtil; + } + + @Override + public String getStringProperty(final String name) { + return propsUtil.getStringProperty(PREFIX + name); + } +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/package-info.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/package-info.java new file mode 100644 index 00000000000..bb068e665d9 --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/internal/package-info.java @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@Export +@NullMarked +@Version("3.0.0") +package org.apache.logging.log4j.kit.env.internal; + +import org.jspecify.annotations.NullMarked; +import org.osgi.annotation.bundle.Export; +import org.osgi.annotation.versioning.Version; diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/package-info.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/package-info.java new file mode 100644 index 00000000000..e8ff76f849a --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/package-info.java @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@Export +@NullMarked +@Version("3.0.0") +package org.apache.logging.log4j.kit.env; + +import org.jspecify.annotations.NullMarked; +import org.osgi.annotation.bundle.Export; +import org.osgi.annotation.versioning.Version; diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/BasicPropertyEnvironment.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/BasicPropertyEnvironment.java new file mode 100644 index 00000000000..abdc482c489 --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/BasicPropertyEnvironment.java @@ -0,0 +1,322 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env.support; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.kit.env.Log4jProperty; +import org.apache.logging.log4j.kit.env.PropertyEnvironment; +import org.jspecify.annotations.Nullable; + +/** + * An implementation of {@link PropertyEnvironment} that only uses basic Java functions. + *

+ * Conversion problems are logged using a status logger. + *

+ */ +public abstract class BasicPropertyEnvironment implements PropertyEnvironment { + + private final Logger statusLogger; + + protected BasicPropertyEnvironment(final Logger statusLogger) { + this.statusLogger = statusLogger; + } + + @Override + public Boolean getBooleanProperty(final String name, final Boolean defaultValue) { + return getObjectPropertyWithTypedDefault(name, this::toBoolean, defaultValue); + } + + @Override + public Charset getCharsetProperty(final String name, final Charset defaultValue) { + return getObjectPropertyWithTypedDefault(name, this::toCharset, defaultValue); + } + + @Override + public @Nullable Class getClassProperty(final String name, final Class upperBound) { + return getClassProperty(name, null, upperBound); + } + + @Override + public Class getClassProperty( + final String name, final Class defaultValue, final Class upperBound) { + return getObjectPropertyWithTypedDefault(name, className -> toClass(className, upperBound), defaultValue); + } + + @Override + public Duration getDurationProperty(final String name, final Duration defaultValue) { + return getObjectPropertyWithTypedDefault(name, this::toDuration, defaultValue); + } + + @Override + public Integer getIntegerProperty(final String name, final Integer defaultValue) { + return getObjectPropertyWithTypedDefault(name, this::toInteger, defaultValue); + } + + @Override + public Long getLongProperty(final String name, final Long defaultValue) { + return getObjectPropertyWithTypedDefault(name, this::toLong, defaultValue); + } + + @Override + public abstract @Nullable String getStringProperty(String name); + + @Override + public T getProperty(final Class propertyClass) { + if (!propertyClass.isAnnotationPresent(Log4jProperty.class)) { + throw new IllegalArgumentException("Unsupported configuration properties class '" + propertyClass.getName() + + "': missing '@Log4jProperty' annotation."); + } + return getRecordProperty(null, propertyClass); + } + + protected Class getClassForName(final String className) throws ReflectiveOperationException { + return Class.forName(className); + } + + protected Boolean toBoolean(final String value) { + return Boolean.valueOf(value); + } + + protected @Nullable Charset toCharset(final String value) { + try { + return Charset.forName(value); + } catch (final IllegalCharsetNameException | UnsupportedOperationException e) { + statusLogger.warn("Invalid Charset value '{}': {}", value, e.getMessage(), e); + } + return null; + } + + protected @Nullable Duration toDuration(final CharSequence value) { + try { + return Duration.parse(value); + } catch (final DateTimeParseException e) { + statusLogger.warn("Invalid Duration value '{}': {}", value, e.getMessage(), e); + } + return null; + } + + protected char[] toCharArray(final String value) { + return value.toCharArray(); + } + + @SuppressWarnings("unchecked") + protected @Nullable Class toClass(final String className, final Class upperBound) { + try { + final Class clazz = getClassForName(className); + if (upperBound.isAssignableFrom(clazz)) { + return (Class) clazz; + } + statusLogger.warn("Invalid Class value '{}': class does not extend {}.", className, upperBound.getName()); + } catch (final ReflectiveOperationException e) { + statusLogger.warn("Invalid Class value '{}': {}", className, e.getMessage(), e); + } + return null; + } + + protected > @Nullable T toEnum(final String value, final Class enumClass) { + try { + return Enum.valueOf(enumClass, value); + } catch (final IllegalArgumentException e) { + statusLogger.warn("Invalid enum value '{}' of type {}.", value, enumClass.getName(), e); + } + return null; + } + + protected @Nullable Integer toInteger(final String value) { + try { + return Integer.valueOf(value); + } catch (final NumberFormatException e) { + statusLogger.warn("Invalid integer value '{}': {}.", value, e.getMessage(), e); + } + return null; + } + + protected @Nullable Long toLong(final String value) { + try { + return Long.valueOf(value); + } catch (final NumberFormatException e) { + statusLogger.warn("Invalid long value '{}': {}.", value, e.getMessage(), e); + } + return null; + } + + protected @Nullable Level toLevel(final String value) { + return Level.toLevel(value, null); + } + + private T getRecordProperty(final @Nullable String parentPrefix, final Class propertyClass) { + if (!propertyClass.isRecord()) { + throw new IllegalArgumentException("Unsupported configuration properties class '" + propertyClass.getName() + + "': class is not a record."); + } + final String prefix = + parentPrefix != null ? parentPrefix : getPropertyName(propertyClass, propertyClass::getSimpleName); + + @SuppressWarnings("unchecked") + final Constructor[] constructors = (Constructor[]) propertyClass.getDeclaredConstructors(); + if (constructors.length == 0) { + throw new IllegalArgumentException("Unsupported configuration properties class '" + propertyClass.getName() + + "': missing public constructor."); + } else if (constructors.length > 1) { + throw new IllegalArgumentException("Unsupported configuration properties class '" + propertyClass.getName() + + "': more than one constructor found."); + } + final Constructor constructor = constructors[0]; + + final Parameter[] parameters = constructor.getParameters(); + final @Nullable Object[] initArgs = new Object[parameters.length]; + for (int i = 0; i < initArgs.length; i++) { + final String name = prefix + "." + getPropertyName(parameters[i], parameters[i]::getName); + final String defaultValue = getPropertyDefaultAsString(parameters[i]); + initArgs[i] = getObjectProperty(name, parameters[i].getParameterizedType(), defaultValue); + } + try { + return constructor.newInstance(initArgs); + } catch (final ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Unable to parse configuration properties class " + propertyClass.getName() + ": " + e.getMessage(), + e); + } + } + + private @Nullable Object getObjectProperty( + final String name, final Type type, final @Nullable String defaultValue) { + if (type instanceof final ParameterizedType parameterizedType + && parameterizedType.getRawType().equals(Class.class)) { + final Type[] arguments = parameterizedType.getActualTypeArguments(); + final Class upperBound = arguments.length > 0 ? findUpperBound(arguments[0]) : Object.class; + return getObjectPropertyWithStringDefault(name, defaultValue, className -> toClass(className, upperBound)); + } + if (type instanceof final Class clazz) { + if (clazz.isRecord()) { + return getRecordProperty(name, clazz); + } + if (char[].class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, defaultValue, this::toCharArray); + } + if (boolean.class.equals(clazz)) { + return getObjectPropertyWithStringDefault( + name, Objects.toString(defaultValue, "false"), this::toBoolean); + } + if (Boolean.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, defaultValue, this::toBoolean); + } + if (Charset.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, defaultValue, this::toCharset); + } + if (Duration.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, defaultValue, this::toDuration); + } + if (Enum.class.isAssignableFrom(clazz)) { + return getObjectPropertyWithStringDefault( + name, defaultValue, value -> toEnum(value, (Class) clazz)); + } + if (int.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, Objects.toString(defaultValue, "0"), this::toInteger); + } + if (Integer.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, defaultValue, this::toInteger); + } + if (long.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, Objects.toString(defaultValue, "0"), this::toLong); + } + if (Long.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, defaultValue, this::toLong); + } + if (Level.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, defaultValue, this::toLevel); + } + if (String.class.equals(clazz)) { + return getObjectPropertyWithStringDefault(name, defaultValue, x -> x); + } + } + throw new IllegalArgumentException("Unsupported property of type '" + type.getTypeName() + "'"); + } + + private Class findUpperBound(final Type type) { + final Type[] bounds; + if (type instanceof final TypeVariable typeVariable) { + bounds = typeVariable.getBounds(); + } else if (type instanceof final WildcardType wildcardType) { + bounds = wildcardType.getUpperBounds(); + } else { + bounds = new Type[0]; + } + return bounds.length > 0 && bounds[0] instanceof final Class clazz ? clazz : Object.class; + } + + private String getPropertyName(final AnnotatedElement element, final Supplier fallback) { + if (element.isAnnotationPresent(Log4jProperty.class)) { + final String specifiedName = + element.getAnnotation(Log4jProperty.class).name(); + if (!specifiedName.isEmpty()) { + return specifiedName; + } + } + return fallback.get(); + } + + private @Nullable String getPropertyDefaultAsString(final AnnotatedElement parameter) { + if (parameter.isAnnotationPresent(Log4jProperty.class)) { + final String defaultValue = + parameter.getAnnotation(Log4jProperty.class).defaultValue(); + if (!defaultValue.isEmpty()) { + return defaultValue; + } + } + return null; + } + + private @Nullable Object getObjectPropertyWithStringDefault( + final String name, final @Nullable String defaultValue, final Function converter) { + final String prop = getStringProperty(name); + if (prop != null) { + final @Nullable Object value = converter.apply(prop); + if (value != null) { + return value; + } + } + return defaultValue != null ? converter.apply(defaultValue) : null; + } + + private T getObjectPropertyWithTypedDefault( + final String name, final Function converter, final T defaultValue) { + final String prop = getStringProperty(name); + if (prop != null) { + final @Nullable T value = converter.apply(prop); + if (value != null) { + return value; + } + } + return defaultValue; + } +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/ClassLoaderPropertyEnvironment.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/ClassLoaderPropertyEnvironment.java new file mode 100644 index 00000000000..e92ed5dff1a --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/ClassLoaderPropertyEnvironment.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env.support; + +import org.apache.logging.log4j.Logger; + +/** + * An environment implementation that uses a specific classloader to load classes. + */ +public abstract class ClassLoaderPropertyEnvironment extends BasicPropertyEnvironment { + + private final ClassLoader loader; + + public ClassLoaderPropertyEnvironment(final ClassLoader loader, final Logger statusLogger) { + super(statusLogger); + this.loader = loader; + } + + @Override + protected Class getClassForName(final String className) throws ReflectiveOperationException { + return Class.forName(className, true, loader); + } +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/CompositePropertyEnvironment.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/CompositePropertyEnvironment.java new file mode 100644 index 00000000000..a5047a33943 --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/CompositePropertyEnvironment.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env.support; + +import java.util.Collection; +import java.util.Comparator; +import java.util.Objects; +import java.util.TreeSet; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.kit.env.PropertyEnvironment; +import org.apache.logging.log4j.kit.env.PropertySource; +import org.jspecify.annotations.Nullable; + +/** + * An environment implementation that supports multiple {@link PropertySource}s. + */ +public class CompositePropertyEnvironment extends ClassLoaderPropertyEnvironment { + + private final Collection sources = + new TreeSet<>(Comparator.comparing(PropertySource::getPriority).reversed()); + + public CompositePropertyEnvironment( + final @Nullable PropertyEnvironment parentEnvironment, + final Collection sources, + final ClassLoader loader, + final Logger statusLogger) { + super(loader, statusLogger); + this.sources.addAll(sources); + if (parentEnvironment != null) { + this.sources.add(new ParentEnvironmentPropertySource(parentEnvironment)); + } + } + + @Override + public @Nullable String getStringProperty(final String name) { + return sources.stream() + .map(source -> source.getProperty(name)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private record ParentEnvironmentPropertySource(PropertyEnvironment parentEnvironment) implements PropertySource { + + @Override + public int getPriority() { + return Integer.MIN_VALUE; + } + + @Override + public @Nullable String getProperty(final String name) { + return parentEnvironment.getStringProperty(name); + } + } +} diff --git a/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/package-info.java b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/package-info.java new file mode 100644 index 00000000000..af29b5a5a37 --- /dev/null +++ b/log4j-kit/src/main/java/org/apache/logging/log4j/kit/env/support/package-info.java @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@Export +@NullMarked +@Version("3.0.0") +package org.apache.logging.log4j.kit.env.support; + +import org.jspecify.annotations.NullMarked; +import org.osgi.annotation.bundle.Export; +import org.osgi.annotation.versioning.Version; diff --git a/log4j-kit/src/test/java/org/apache/logging/log4j/kit/env/support/BasicPropertyEnvironmentTest.java b/log4j-kit/src/test/java/org/apache/logging/log4j/kit/env/support/BasicPropertyEnvironmentTest.java new file mode 100644 index 00000000000..4716ef565c1 --- /dev/null +++ b/log4j-kit/src/test/java/org/apache/logging/log4j/kit/env/support/BasicPropertyEnvironmentTest.java @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.env.support; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.kit.env.Log4jProperty; +import org.apache.logging.log4j.kit.env.PropertyEnvironment; +import org.apache.logging.log4j.kit.logger.TestListLogger; +import org.apache.logging.log4j.spi.StandardLevel; +import org.apache.logging.log4j.status.StatusLogger; +import org.assertj.core.api.Assertions; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Tests the property values that are used as properties. + */ +class BasicPropertyEnvironmentTest { + + @Log4jProperty + record BasicValues(boolean boolAttr, int intAttr, long longAttr) {} + + @Log4jProperty + record DefaultBasicValues( + @Log4jProperty(defaultValue = "true") boolean boolAttr, + @Log4jProperty(defaultValue = "123") int intAttr, + @Log4jProperty(defaultValue = "123456") long longAttr) {} + + @Log4jProperty(name = "BasicValues") + record BoxedBasicValues(@Nullable Boolean boolAttr, @Nullable Integer intAttr, @Nullable Long longAttr) {} + + private static final Map BASIC_PROPS = + Map.of("BasicValues.boolAttr", "true", "BasicValues.intAttr", "123", "BasicValues.longAttr", "123456"); + + @Test + void should_support_basic_values() { + assertMapConvertsTo(Map.of(), new BasicValues(false, 0, 0L)); + assertMapConvertsTo(BASIC_PROPS, new BasicValues(true, 123, 123456)); + // Default values + assertMapConvertsTo(Map.of(), new DefaultBasicValues(true, 123, 123456)); + } + + @Test + void should_support_boxed_values() { + assertMapConvertsTo(Map.of(), new BoxedBasicValues(null, null, null)); + assertMapConvertsTo(BASIC_PROPS, new BoxedBasicValues(true, 123, 123456L)); + // No need to test default values, since properties with a default value should be primitives + } + + @Log4jProperty + record ScalarValues( + @Nullable Charset charsetAttr, + @Nullable Duration durationAttr, + @Nullable String stringAttr, + @Nullable StandardLevel enumAttr, + @Nullable Level levelAttr) {} + + @Log4jProperty + record DefaultScalarValues( + @Log4jProperty(defaultValue = "UTF-8") Charset charsetAttr, + @Log4jProperty(defaultValue = "PT8H") Duration durationAttr, + @Log4jProperty(defaultValue = "Hello child!") String stringAttr, + @Log4jProperty(defaultValue = "WARN") StandardLevel enumAttr, + @Log4jProperty(defaultValue = "INFO") Level levelAttr) {} + + private static final Map SCALAR_PROPS = Map.of( + "ScalarValues.charsetAttr", + "UTF-8", + "ScalarValues.durationAttr", + "PT8H", + "ScalarValues.stringAttr", + "Hello child!", + "ScalarValues.enumAttr", + "WARN", + "ScalarValues.levelAttr", + "INFO"); + + @Test + void should_support_scalar_values() { + assertMapConvertsTo(Map.of(), new ScalarValues(null, null, null, null, null)); + assertMapConvertsTo( + SCALAR_PROPS, + new ScalarValues(UTF_8, Duration.ofHours(8), "Hello child!", StandardLevel.WARN, Level.INFO)); + // Default values + assertMapConvertsTo( + SCALAR_PROPS, + new DefaultScalarValues(UTF_8, Duration.ofHours(8), "Hello child!", StandardLevel.WARN, Level.INFO)); + } + + @Log4jProperty + record ArrayValues(char @Nullable [] password) {} + + private static final Map ARRAY_PROPS = Map.of("ArrayValues.password", "changeit"); + + @Test + void should_support_arrays_of_scalars() { + final TestListLogger logger = new TestListLogger(BasicPropertyEnvironmentTest.class.getName()); + // Missing properties + PropertyEnvironment env = new TestPropertyEnvironment(Map.of(), logger); + ArrayValues actual = env.getProperty(ArrayValues.class); + assertThat(actual.password()).isNull(); + // With properties + env = new TestPropertyEnvironment(ARRAY_PROPS, logger); + actual = env.getProperty(ArrayValues.class); + assertThat(actual.password()).containsExactly("changeit".toCharArray()); + // Check for warnings + assertThat(logger.getMessages()).isEmpty(); + } + + @Log4jProperty + record Component(@Nullable String type, SubComponent subComponent) {} + + // Subcomponents shouldn't be annotated. + record SubComponent(@Nullable String type) {} + + private static final Map COMPONENT_PROPS = + Map.of("Component.type", "COMPONENT", "Component.subComponent.type", "SUBCOMPONENT"); + + @Test + void should_support_nested_records() { + assertMapConvertsTo(Map.of(), new Component(null, new SubComponent(null))); + assertMapConvertsTo(COMPONENT_PROPS, new Component("COMPONENT", new SubComponent("SUBCOMPONENT"))); + } + + @Log4jProperty + record BoundedClass(Class className) {} + + @Log4jProperty + record BoundedClassParam(Class className) {} + + static Stream should_support_classes_with_bounds() { + return Stream.of( + Arguments.of( + "BoundedClass.className", + "java.lang.String", + BoundedClass.class, + new BoundedClass(null), + List.of("Invalid Class value 'java.lang.String': class does not extend java.lang.Number.")), + Arguments.of( + "BoundedClassParam.className", + "java.lang.String", + BoundedClassParam.class, + new BoundedClassParam(null), + List.of("Invalid Class value 'java.lang.String': class does not extend java.lang.Number.")), + Arguments.of( + "BoundedClass.className", + "java.lang.Integer", + BoundedClass.class, + new BoundedClass(Integer.class), + Collections.emptyList()), + Arguments.of( + "BoundedClassParam.className", + "java.lang.Integer", + BoundedClassParam.class, + new BoundedClassParam(Integer.class), + Collections.emptyList())); + } + + @ParameterizedTest + @MethodSource + void should_support_classes_with_bounds( + final String key, + final String value, + final Class clazz, + final Object expected, + final Iterable expectedMessages) { + final TestListLogger logger = new TestListLogger(BasicPropertyEnvironmentTest.class.getName()); + final PropertyEnvironment env = new TestPropertyEnvironment(Map.of(key, value), logger); + assertThat(env.getProperty(clazz)).isEqualTo(expected); + Assertions.assertThat(logger.getMessages()).containsExactlyElementsOf(expectedMessages); + } + + private void assertMapConvertsTo(final Map map, final Object expected) { + final TestListLogger logger = new TestListLogger(BasicPropertyEnvironmentTest.class.getName()); + final PropertyEnvironment env = new TestPropertyEnvironment(map, logger); + final Object actual = env.getProperty(expected.getClass()); + assertThat(actual).isEqualTo(expected); + assertThat(logger.getMessages()).isEmpty(); + } + + private static class TestPropertyEnvironment extends BasicPropertyEnvironment { + + private final Map props; + + public TestPropertyEnvironment(final Map props) { + this(props, StatusLogger.getLogger()); + } + + public TestPropertyEnvironment(final Map props, final Logger logger) { + super(logger); + this.props = props; + } + + @Override + public String getStringProperty(final String name) { + return props.get(name); + } + } +} diff --git a/log4j-kit/src/test/java/org/apache/logging/log4j/kit/logger/AbstractLoggerTest.java b/log4j-kit/src/test/java/org/apache/logging/log4j/kit/logger/AbstractLoggerTest.java index adc350922ea..a6138ecea12 100644 --- a/log4j-kit/src/test/java/org/apache/logging/log4j/kit/logger/AbstractLoggerTest.java +++ b/log4j-kit/src/test/java/org/apache/logging/log4j/kit/logger/AbstractLoggerTest.java @@ -26,7 +26,7 @@ import javassist.bytecode.MethodInfo; import org.junit.jupiter.api.Test; -public class AbstractLoggerTest { +class AbstractLoggerTest { private static final int MAX_INLINE_SIZE = 35; /** diff --git a/log4j-kit/src/test/java/org/apache/logging/log4j/kit/logger/TestListLogger.java b/log4j-kit/src/test/java/org/apache/logging/log4j/kit/logger/TestListLogger.java new file mode 100644 index 00000000000..acf6f3689d0 --- /dev/null +++ b/log4j-kit/src/test/java/org/apache/logging/log4j/kit/logger/TestListLogger.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.kit.logger; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.internal.recycler.DummyRecyclerFactoryProvider; +import org.apache.logging.log4j.message.DefaultFlowMessageFactory; +import org.apache.logging.log4j.message.FlowMessageFactory; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.message.MessageFactory; +import org.apache.logging.log4j.message.ParameterizedNoReferenceMessageFactory; +import org.apache.logging.log4j.spi.recycler.RecyclerFactory; +import org.apache.logging.log4j.status.StatusLogger; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class TestListLogger extends AbstractLogger { + + private static final MessageFactory MESSAGE_FACTORY = ParameterizedNoReferenceMessageFactory.INSTANCE; + private static final FlowMessageFactory FLOW_MESSAGE_FACTORY = new DefaultFlowMessageFactory(); + private static final RecyclerFactory RECYCLER_FACTORY = + new DummyRecyclerFactoryProvider().createForEnvironment(null); + + private final List messages = new ArrayList<>(); + + public TestListLogger(final String name) { + super(name, MESSAGE_FACTORY, FLOW_MESSAGE_FACTORY, RECYCLER_FACTORY, StatusLogger.getLogger()); + } + + @Override + public Level getLevel() { + return Level.DEBUG; + } + + @Override + public boolean isEnabled(final Level level, @Nullable final Marker marker) { + return Level.DEBUG.isLessSpecificThan(level); + } + + @Override + protected void doLog( + final String fqcn, + final @Nullable StackTraceElement location, + final Level level, + final @Nullable Marker marker, + final @Nullable Message message, + final @Nullable Throwable throwable) { + messages.add(message != null ? message.getFormattedMessage() : ""); + } + + public List getMessages() { + return Collections.unmodifiableList(messages); + } +}