ConfigPropertySourcePostProcessor.java

package dev.orne.config.spring;

/*-
 * #%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 javax.validation.constraints.NotNull;

import org.apiguardian.api.API;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.BeanInitializationException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.annotation.AnnotatedGenericBeanDefinition;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.EnvironmentAware;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.Environment;
import org.springframework.core.type.AnnotationMetadata;

import dev.orne.config.Config;

/**
 * Spring bean factory post processor that adds to the Spring environment
 * the property sources defined by {@code @ConfigPropertySource} annotations
 * on {@code @Configuration} beans.
 * 
 * @author <a href="https://github.com/ihernaez">(w) Iker Hernaez</a>
 * @version 1.0, 2025-08
 * @since 1.0
 * @see ConfigPropertySource
 */
@API(status = API.Status.INTERNAL, since = "1.0")
public class ConfigPropertySourcePostProcessor
implements EnvironmentAware, BeanFactoryPostProcessor {

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

    /** The Spring environment. */
    protected ConfigurableEnvironment environment;

    /**
     * Creates a new instance.
     */
    public ConfigPropertySourcePostProcessor() {
        super();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void setEnvironment(
            final @NotNull Environment environment) {
        if (environment instanceof ConfigurableEnvironment) {
            this.environment = (ConfigurableEnvironment) environment;
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void postProcessBeanFactory(
            final ConfigurableListableBeanFactory beanFactory)
    throws BeansException {
        if (this.environment == null) {
            throw new BeanInitializationException(
                    "@ConfigPropertySource cannot be used without a ConfigurableEnvironment");
        }
        for (final String beanName : beanFactory.getBeanDefinitionNames()) {
            final BeanDefinition def = beanFactory.getBeanDefinition(beanName);
            if (def instanceof AnnotatedGenericBeanDefinition) {
                final AnnotatedGenericBeanDefinition agbd = (AnnotatedGenericBeanDefinition) def;
                final AnnotationMetadata annotations = agbd.getMetadata();
                if (annotations.hasAnnotation(Configuration.class.getName())) {
                    processConfigurationBean(beanFactory, beanName, annotations);
                }
            }
        }
    }

    /**
     * Process the {@code @ConfigPropertySource} annotations on the given
     * configuration bean and add the corresponding property sources to the
     * environment.
     * 
     * @param beanFactory The bean factory to retrieve configuration beans from.
     * @param configurationBean The name of the configuration bean.
     * @param annotations The {@code Configuration} bean annotation metadata
     *                    to process.
     * @throws BeanInitializationException If the configuration bean cannot be
     *         found and {@code optional} is {@code false}, or if
     *         multiple configuration beans of the specified type are found.
     */
    protected void processConfigurationBean(
            final ConfigurableListableBeanFactory beanFactory,
            final String configurationBean,
            final AnnotationMetadata annotations) {
        processSingleAnnotations(beanFactory, configurationBean, annotations);
        processAggregateAnnotations(beanFactory, configurationBean, annotations);
    }

    /**
     * Process single {@code @ConfigPropertySource} annotations on the given
     * class and add the corresponding property sources to the environment.
     * 
     * @param beanFactory The bean factory to retrieve configuration beans from.
     * @param configurationBean The name of the configuration bean.
     * @param annotations The {@code Configuration} bean annotation metadata.
     * @throws BeanInitializationException If the configuration bean cannot be
     *         found and {@code optional} is {@code false}, or if
     *         multiple configuration beans of the specified type are found.
     */
    protected void processSingleAnnotations(
            final ConfigurableListableBeanFactory beanFactory,
            final String configurationBean,
            final AnnotationMetadata annotations) {
        AnnotationAttributes annotation = AnnotationAttributes.fromMap(
                annotations.getAnnotationAttributes(ConfigPropertySource.class.getName()));
        if (annotation != null) {
            processSource(beanFactory, configurationBean, annotation);
        }
    }

    /**
     * Process aggregated {@code @ConfigPropertySources} annotations on the
     * given class and add the corresponding property sources to the
     * environment.
     * 
     * @param beanFactory The bean factory to retrieve configuration beans from.
     * @param configurationBean The name of the configuration bean.
     * @param annotations The {@code Configuration} bean annotation metadata.
     * @throws BeanInitializationException If the configuration bean cannot be
     *         found and {@code optional} is {@code false}, or if
     *         multiple configuration beans of the specified type are found.
     */
    protected void processAggregateAnnotations(
            final @NotNull ConfigurableListableBeanFactory beanFactory,
            final @NotNull String configurationBean,
            final @NotNull AnnotationMetadata annotations) {
        AnnotationAttributes aggregator = AnnotationAttributes.fromMap(
                annotations.getAnnotationAttributes(ConfigPropertySources.class.getName()));
        if (aggregator != null) {
            final AnnotationAttributes[] childs = aggregator.getAnnotationArray(
                    "value");
            for (final AnnotationAttributes annotation: childs) {
                processSource(beanFactory, configurationBean, annotation);
            }
        }
    }

    /**
     * Process a single {@code @ConfigPropertySource} annotation and add the
     * corresponding property source to the environment.
     * 
     * @param beanFactory The bean factory to retrieve configuration beans from.
     * @param configurationBean The name of the configuration bean.
     * @param annotation The {@code ConfigPropertySource} annotation data
     *                   to process.
     * @throws BeanInitializationException If the configuration bean cannot be
     *         found and {@code optional} is {@code false}, or if
     *         multiple configuration beans of the specified type are found.
     */
    protected void processSource(
            final @NotNull ConfigurableListableBeanFactory beanFactory,
            final @NotNull String configurationBean,
            final @NotNull AnnotationAttributes annotation) {
        String configName = annotation.getString("name");
        if (configName.isEmpty()) {
            configName = annotation.getString("value");
        }
        final boolean ignoreMissing = annotation.getBoolean("optional");
        final Class<? extends Config> configType = annotation.getClass("type");
        final String beanName;
        if (configName.isEmpty()) {
            if (ConfigPropertySource.Unconfigured.class.equals(configType)) {
                throw new BeanInitializationException(String.format(
                        "No Config type or name specified in @ConfigPropertySource on '%s'. "
                        + "Either specify a unique Config bean name with \"name\", "
                        + "or a Config bean type with \"type\" or \"value\".",
                        configurationBean));
            }
            beanName = findBeanByType(beanFactory, configType, ignoreMissing);
        } else {
            if (!ConfigPropertySource.Unconfigured.class.equals(configType)) {
                throw new BeanInitializationException(String.format(
                        "Both Config type or name specified in @ConfigPropertySource on '%s'. "
                        + "Either specify a unique Config bean name with \"name\", "
                        + "or a Config bean type with \"type\" or \"value\".",
                        configurationBean));
            }
            beanName = validateConfigName(beanFactory, configName, ignoreMissing);
        }
        if (beanName != null) {
            final String sourceName = ConfigPropertySource.SOURCE_PREFIX + beanName;
            final ConfigLazyPropertySource propertySource = new ConfigLazyPropertySource(
                    sourceName, beanFactory, beanName);
            if (this.environment.getPropertySources().contains(sourceName)) {
                LOG.debug("Replacing '{}' property source...",
                        sourceName);
                this.environment.getPropertySources().remove(sourceName);
                this.environment.getPropertySources().addFirst(propertySource);
            } else {
                LOG.debug("Adding '{}' property source...",
                        sourceName);
                this.environment.getPropertySources().addFirst(propertySource);
            }
        }
    }

    /**
     * Validates the the specified bean name corresponds to a {@code Config}
     * bean, and returns the name if valid.
     * 
     * @param beanFactory The bean factory to retrieve configuration beans from.
     * @param configName The name of the configuration bean.
     * @param ignoreMissing Whether to ignore a missing configuration bean.
     * @return The configuration bean name, or {@code null} if not found and
     *         {@code ignoreMissing} is {@code true}.
     * @throws BeanInitializationException If no configuration bean is found
     *         and {@code ignoreMissing} is {@code false}.
     */
    protected String validateConfigName(
            final @NotNull ConfigurableListableBeanFactory beanFactory,
            final @NotNull String configName,
            final boolean ignoreMissing) {
        try {
            final BeanDefinition configDef = beanFactory.getMergedBeanDefinition(configName);
            final ResolvableType beanType = configDef.getResolvableType();
            if (beanType.as(Config.class) != ResolvableType.NONE) {
                return configName;
            } else {
                throw new BeanInitializationException(
                        "Bean with name '" + configName + "' is not of type Config (" + configDef.getBeanClassName() + "). " +
                        "Ensure that the specified bean is a Config bean.");
            }
        } catch (final NoSuchBeanDefinitionException e) {
            if (ignoreMissing) {
                LOG.debug("No Config bean found with name '{}', but ignoring missing as requested.",
                        configName);
                return null;
            } else {
                throw new BeanInitializationException(
                        "No Config bean found with name '" + configName + "'. " +
                        "Ensure that a Config bean is defined with the specified name.",
                        e);
            }
        }
    }

    /**
     * Finds a unique bean of the specified configuration type, and returns
     * its name.
     * <p>
     * Does consider prototypes and objects created by FactoryBeans, which
     * means that FactoryBeans will get initialized.
     * If the object created by the FactoryBean doesn't match, the raw
     * FactoryBean itself will be matched against the type.
     * 
     * @param beanFactory The bean factory to retrieve configuration beans from.
     * @param configType The type of the configuration bean.
     * @param ignoreMissing Whether to ignore a missing configuration bean.
     * @return The configuration bean name, or {@code null} if not found and
     *         {@code ignoreMissing} is {@code true}.
     * @throws BeanInitializationException If multiple configuration beans of
     *         the specified type are found, or if no configuration bean is
     *         found and {@code ignoreMissing} is {@code false}.
     */
    protected String findBeanByType(
            final @NotNull ConfigurableListableBeanFactory beanFactory,
            final @NotNull Class<? extends Config> configType,
            final boolean ignoreMissing) {
        final String[] beanNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(
                beanFactory,
                configType);
        if (beanNames.length > 1) {
            throw new BeanInitializationException(
                    "Multiple Config beans found of type '" + configType.getName() + "'. " +
                    "Specify a unique bean name with \"name\" to disambiguate.");
        } else if (beanNames.length == 0) {
            if (ignoreMissing) {
                LOG.debug("No Config bean found of type '{}', but ignoring missing as requested.",
                        configType.getName());
                return null;
            } else {
                throw new BeanInitializationException(
                        "No Config bean found of type '" + configType.getName() + "'. " +
                        "Ensure that a Config bean of the specified type is defined.");
            }
        } else {
            return beanNames[0];
        }
    }
}