ConfigurerImpl.java

package dev.orne.config.impl;

/*-
 * #%L
 * Orne Config
 * %%
 * Copyright (C) 2019 - 2025 Orne Developments
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Lesser Public License for more details.
 * 
 * You should have received a copy of the GNU General Lesser Public
 * License along with this program.  If not, see
 * <http://www.gnu.org/licenses/lgpl-3.0.html>.
 * #L%
 */

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

import javax.validation.constraints.NotNull;

import org.apache.commons.beanutils.ConversionException;
import org.apache.commons.beanutils.ConvertUtilsBean;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.commons.lang3.ClassUtils;
import org.apache.commons.lang3.Validate;
import org.apiguardian.api.API;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.helpers.MessageFormatter;

import dev.orne.config.Config;
import dev.orne.config.ConfigException;
import dev.orne.config.ConfigProvider;
import dev.orne.config.Configurable;
import dev.orne.config.ConfigurableProperty;
import dev.orne.config.ConfigurationOptions;
import dev.orne.config.Configurer;
import dev.orne.config.PreferredConfig;

/**
 * Default implementation of {@code Configurer}.
 * 
 * @author <a href="https://github.com/ihernaez">(w) Iker Hernaez</a>
 * @version 1.0, 2019-07
 * @version 2.0, 2025-07
 * @since 0.1
 * @see Configurer
 */
@API(status = API.Status.INTERNAL, since = "1.0")
public class ConfigurerImpl
implements Configurer {

    /** The class logger. */
    private static final Logger LOG = LoggerFactory.getLogger(ConfigurerImpl.class);

    /** The configuration provider. */
    private final @NotNull ConfigProvider configProvider;
    /** The value converter. */
    private final @NotNull ConvertUtilsBean converter;

    /**
     * Creates a new instance.
     * 
     * @param configProvider The configuration provider
     */
    public ConfigurerImpl(
            final @NotNull ConfigProvider configProvider) {
        this(configProvider, defaultConverter());
    }

    /**
     * Creates a new instance.
     * 
     * @param configProvider The configuration provider
     * @param converter The value converter.
     */
    public ConfigurerImpl(
            final @NotNull ConfigProvider configProvider,
            final @NotNull ConvertUtilsBean converter) {
        Validate.notNull(configProvider, "A valid configuration provider is required.");
        this.configProvider = configProvider;
        Validate.notNull(configProvider, "A valid value converter is required.");
        this.converter = converter;
    }

    /**
     * <p>Creates a new value converter configured with the default settings.</p>
     * 
     * <p>This converter is configured to:</p>
     * <ul>
     * <li>Return {@code null} for {@code null} values.
     * <li>Return {@code null} for values of incompatible types.
     * <li>Return empty arrays for {@code null} values.
     * <li>Return empty collections for {@code null} values.
     * </ul>
     * 
     * @return A new value converter configured with the default settings
     */
    public static @NotNull ConvertUtilsBean defaultConverter() {
        final ConvertUtilsBean result = new ConvertUtilsBean();
        result.register(false, true, 0);
        return result;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void configure(
            final @NotNull Configurable bean) {
        Validate.notNull(bean, "A not null bean is required.");
        final Class<?> componentClass = bean.getClass();
        final PreferredConfig preferences = componentClass.getAnnotation(
                PreferredConfig.class);
        final ConfigurationOptions options = componentClass.getAnnotation(
                ConfigurationOptions.class);
        final Config config = this.configProvider.selectConfig(preferences);
        if (config != null) {
            if (options == null || options.configureProperties()) {
                configureProperties(bean, config);
            }
            bean.configure(config);
            if (options != null && options.configureNestedBeans()) {
                configureNestedBeans(bean, config);
            }
        }
    }

    /**
     * Configures the properties of the bean passed as argument with the
     * selected configuration.
     * 
     * @param bean The bean which properties configure
     * @param config The configuration to use
     */
    protected void configureProperties(
            final @NotNull Configurable bean,
            final @NotNull Config config) {
        final Collection<Field> fields = scanConfigurableProperties(bean.getClass());
        for (final Field field : fields) {
            configureProperty(bean, field, config);
        }
    }

    /**
     * Scans the fields annotated with {@code ConfigurableProperty} in the
     * bean class passed as argument.
     * 
     * @param targetClass The bean class to scan for configurable fields
     * @return The configurable fields detected
     */
    protected @NotNull Collection<Field> scanConfigurableProperties(
            final Class<?> targetClass) {
        final Set<Field> configurableFields = new HashSet<>();
        Class<?> currentClass = targetClass;
        while (currentClass != null) {
            for (final Field field : currentClass.getDeclaredFields()) {
                if (field.isAnnotationPresent(ConfigurableProperty.class)) {
                    configurableFields.add(field);
                }
            }
            currentClass = currentClass.getSuperclass();
        }
        return configurableFields;
    }

    /**
     * Configures the requested property in the bean with the provided
     * configuration.
     * 
     * @param bean The instance of the bean
     * @param field The property of the bean to configure
     * @param config The configuration to use
     */
    protected void configureProperty(
            final @NotNull Object bean,
            final @NotNull Field field,
            final @NotNull Config config) {
        final ConfigurableProperty metadata = field.getAnnotation(ConfigurableProperty.class);
        final String key = metadata.value();
        final Class<?> type = field.getType();
        try {
            if (config.contains(key)) {
                final String strValue = config.get(key);
                if (type.isPrimitive()) {
                    final Class<?> wrapperType = ClassUtils.primitiveToWrapper(type);
                    final Object wrapperValue = convertValue(strValue, wrapperType);
                    if (wrapperValue == null) {
                        LOG.warn("Null value in key '{}' for type {}", key, type);
                    } else {
                        setPropertyValue(bean, field, wrapperValue);
                    }
                } else {
                    setPropertyValue(bean, field, convertValue(strValue, type));
                }
            }
        } catch (final ConfigException ce) {
            LOG.error(String.format("Error configuring property '%s' on bean of class %s",
                    field.getName(),
                    bean.getClass()), ce);
        }
    }

    /**
     * Converts the configuration property value to the specified target type.
     * 
     * @param <T> The target type.
     * @param value The configuration property value.
     * @param type The target type.
     * @return The converted configuration value.
     * throws ConfigException If an error occurs converting the value.
     */
    protected <T> T convertValue(
            final String value,
            final Class<T> type) {
        try {
            return type.cast(this.converter.convert(value, type));
        } catch (final ConversionException e) {
            throw new ConfigException("Error converting configuration property value", e);
        }
    }

    /**
     * Sets the specified property of the specified bean with the specified
     * value.
     * 
     * @param bean The instance of the bean
     * @param field The property of the bean to set
     * @param value The value to set
     */
    protected void setPropertyValue(
            final @NotNull Object bean,
            final @NotNull Field field,
            final Object value) {
        try {
            PropertyUtils.setProperty(bean, field.getName(), value);
        } catch (final IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
            LOG.error(String.format(
                    "Error setting property '%s' on bean of class %s",
                    field.getName(),
                    bean.getClass()), e);
        }
    }

    /**
     * Configures the nested beans of the bean passed as argument with the
     * selected configuration.
     * 
     * @param bean The bean which nested beans configure
     * @param config The configuration to use
     */
    protected void configureNestedBeans(
            final @NotNull Configurable bean,
            final @NotNull Config config) {
        final Collection<Configurable> nestedBeans = scanNestedComponents(bean);
        for (final Configurable nestedBean : nestedBeans) {
            if (!nestedBean.isConfigured()) {
                configure(nestedBean);
            }
        }
    }

    /**
     * Scans the fields of the bean passed as argument for instances of
     * {@code Configurable} not configured.
     * 
     * @param bean The bean to scan for unconfigured nested beans
     * @return The nested beans detected
     */
    protected @NotNull Collection<Configurable> scanNestedComponents(
            final @NotNull Object bean) {
        final Set<Configurable> nestedComponents = new HashSet<>();
        Class<?> currentClass = bean.getClass();
        while (currentClass != null) {
            for (final Field field : currentClass.getDeclaredFields()) {
                try {
                    final Object fieldValue = PropertyUtils.getProperty(bean, field.getName());
                    if (fieldValue instanceof Configurable) {
                        final Configurable nestedComponent = (Configurable) fieldValue;
                        nestedComponents.add(nestedComponent);
                    }
                } catch (final IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
                    LOG.error(MessageFormatter.format(
                            "Error accessing property '{}' on bean of class {}",
                            field.getName(),
                            currentClass).getMessage(), e);
                }
            }
            currentClass = currentClass.getSuperclass();
        }
        return nestedComponents;
    }
}