I18nConfiguration.java

package dev.orne.i18n.context;

/*-
 * #%L
 * Orne I18N
 * %%
 * Copyright (C) 2023 - 2024 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.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import java.util.WeakHashMap;

import javax.validation.constraints.NotNull;

import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Pair;
import org.apiguardian.api.API;
import org.apiguardian.api.API.Status;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import dev.orne.i18n.I18nConfigurationException;

/**
 * Utility class with I18N configuration constants and retrieval methods.
 * <p>
 * Configuration is loaded from application provided {@value #FILE}
 * properties file. Only one configuration file is allowed by class loader.
 * 
 * @author <a href="https://github.com/ihernaez">(w) Iker Hernaez</a>
 * @version 1.0, 2023-11
 * @since 0.1
 */
@API(status=Status.STABLE, since="0.1")
public final class I18nConfiguration {

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

    /** The I18N configuration resource. */
    public static final String FILE = "dev.orne.i18n.config.properties";

    /**
     * The configuration properties prefix.
     */
    public static final String PREFIX = "dev.orne.i18n.";
    /**
     * The configuration property for {@code I18nContextProvider}
     * implementation to use.
     * Takes {@code DEFAULT} by default.
     */
    public static final String CONTEXT_PROVIDER = PREFIX + "context.provider";
    /**
     * The configuration property for {@code I18nContex} inheritance by
     * child threads in per-thread based context providers.
     * Takes {@code true} by default.
     */
    public static final String CONTEXT_INHERITED = PREFIX + "context.inherited";
    /**
     * The configuration property for default language.
     * Takes {@code Locale.getDefault()} by default.
     */
    public static final String DEFAULT_LANGUAGE = PREFIX + "language.default";
    /**
     * The configuration property for comma separated available languages.
     * Takes {@code Locale.getAvailableLocales()} by default.
     */
    public static final String AVAILABLE_LANGUAGES = PREFIX + "language.available";
    /**
     * The configuration property for default {@code I18nResources}
     * bundle base name.
     * Takes {@code messages} by default.
     */
    public static final String DEFAULT_RESOURCES = PREFIX + "resources";
    /**
     * The configuration properties prefix for named {@code I18nResources}
     * bundle base name.
     * <p>
     * To be concatenated with bundle name. For example:
     * {@value NAMED_RESOURCES_PREFIX}{@code .alt-messages}
     * configures resource with name {@code alt-messages}.
     */
    public static final String NAMED_RESOURCES_PREFIX = PREFIX + "resources.named.";

    /** The default configuration resource, relative to this class. */
    @API(status=Status.INTERNAL, since="0.1")
    static final String DEFAULT_CFG = "default-config.properties";
    /** The by ClassLoader I18N configuration cache. */
    @API(status=Status.INTERNAL, since="0.1")
    static final WeakHashMap<ClassLoader, Pair<Properties, Set<String>>> CACHE =
            new WeakHashMap<>();

    /**
     * Private constructor.
     */
    private I18nConfiguration() {
        // Utility class
    }

    /**
     * Returns the I18N configuration for the current class loader.
     * 
     * @return The I18N configuration properties
     * @see #get(ClassLoader)
     * @throws I18nConfigurationException If an error occurs loading the
     * configuration.
     */
    @API(status=Status.EXPERIMENTAL, since="0.1")
    public static @NotNull Properties get() {
        return get(Thread.currentThread().getContextClassLoader());
    }

    /**
     * Returns the I18N configuration for the specified class loader.
     * If the class loader has a {@value #FILE} resource loads the
     * configuration from the file.
     * Otherwise inherits the configuration from the parent class loader and,
     * if none has a custom configuration, applies the default configuration.
     * <p>
     * Note that having more that one {@value #FILE} in a class loader
     * throws a {@code I18nConfigurationException}.
     * 
     * @param cl The class loader to retrieve the configuration for.
     * @return The configuration properties.
     * @see #FILE
     * @throws I18nConfigurationException If an error occurs loading the
     * configuration.
     */
    @API(status=Status.EXPERIMENTAL, since="0.1")
    public static @NotNull Properties get(
            final @NotNull ClassLoader cl) {
        Validate.notNull(cl);
        final Properties copy = new Properties();
        copy.putAll(getCache(cl).getLeft());
        return copy;
    }

    /**
     * Sets the specified configuration for the specified class loader
     * and all its children.
     * 
     * @param cl The class loader.
     * @param config The configuration to apply.
     */
    @API(status=Status.EXPERIMENTAL, since="0.1")
    static synchronized void set(
            final @NotNull ClassLoader cl,
            final @NotNull Properties config) {
        Validate.notNull(cl);
        Validate.notNull(config);
        final Properties copy = new Properties();
        copy.putAll(config);
        final Set<String> clRes = getCache(cl).getRight();
        CACHE.put(cl, Pair.of(copy, clRes));
        CACHE.forEach((key, cache) -> {
            boolean child = key.getParent() != null &&
                    key.getParent().equals(cl);
            if (child && cache.getRight().equals(clRes)) {
                set(key, config);
            }
        });
    }

    /**
     * Resets the I18N configuration cache.
     */
    @API(status=Status.INTERNAL, since="0.1")
    static synchronized void reset() {
        CACHE.clear();
    }

    /**
     * Returns or computes the cached tuple of I18N configuration and applied
     * file URLs for the specified class loader.
     * 
     * @param cl The class loader.
     * @return The tuple of I18N configuration and applied file URLs.
     * @throws I18nConfigurationException If an error occurs loading the
     * configuration.
     */
    @API(status=Status.INTERNAL, since="0.1")
    static synchronized @NotNull Pair<Properties, Set<String>> getCache(
            final @NotNull ClassLoader cl) {
        return CACHE.computeIfAbsent(cl, I18nConfiguration::loadConfiguration);
    }

    /**
     * Computes the cached tuple of I18N configuration and applied file URLs
     * for the specified class loader.
     * <p>
     * If the class loader has a configuration file not inherited from the
     * parent class loader loads the configuration from the file and returns
     * a set with parent class loader's file URLs plus the class loader file
     * URL to detect new configuration files in child class loaders.
     * <p>
     * If the class loader has not a configuration file inherits the
     * configuration and the set of detected file URLs from the parent class
     * loader.
     * <p>
     * If the class loader has multiple configuration files throws a
     * {@code I18nConfigurationException}.
     * <p>
     * Finally if the class loader has no parent (bootstrap class loader)
     * loads the default configuration and returns an empty set of detected
     * configuration files.
     * 
     * @param cl The class loader.
     * @return The tuple of I18N configuration and applied file URLs.
     * @throws I18nConfigurationException If an error occurs loading the
     * configuration.
     */
    @API(status=Status.INTERNAL, since="0.1")
    static @NotNull Pair<Properties, Set<String>> loadConfiguration(
            final @NotNull ClassLoader cl) {
        final Properties config = new Properties();
        final Set<String> resources = new HashSet<>();
        if (cl.getParent() == null) {
            LOG.debug("Loading default I18N configuration for ClassLoader {}", cl);
            try (final InputStream is = I18nConfiguration.class.getResourceAsStream(DEFAULT_CFG)) {
                config.load(is);
            } catch (final IOException e) {
                throw new I18nConfigurationException(
                        "Error loading I18N default configuration",
                        e);
            }
        } else {
            final Pair<Properties, Set<String>> parent = getCache(cl.getParent());
            resources.addAll(parent.getRight());
            final URL file = getClassLoaderConfigFile(cl, parent.getRight());
            if (file == null) {
                LOG.debug("Inheriting parent I18N configuration for ClassLoader {}", cl);
                config.putAll(parent.getLeft());
            } else {
                LOG.debug("Loading I18N configuration for ClassLoader {}", cl);
                try (final InputStream is = file.openStream()) {
                    config.load(is);
                } catch (final IOException e) {
                    throw new I18nConfigurationException(
                            "Error loading custom I18N configuration",
                            e);
                }
                resources.add(file.toString());
            }
        }
        return Pair.of(config, resources);
    }

    /**
     * Finds a new I18N configuration file to apply to the specified class
     * loader.
     * If the class loader has multiple configuration files throws a
     * {@code I18nConfigurationException}.
     * 
     * @param cl The class loader.
     * @param prev The set of configuration files detected in parent class
     * loaders.
     * @return The configuration file URL, if any.
     * @throws I18nConfigurationException If an error occurs loading the
     * configuration or multiple files are detected.
     */
    @API(status=Status.INTERNAL, since="0.1")
    static URL getClassLoaderConfigFile(
            final @NotNull ClassLoader cl,
            final @NotNull Set<String> prev) {
        final List<URL> result = new ArrayList<>();
        try {
            for (final URL resource : Collections.list(cl.getResources(I18nConfiguration.FILE))) {
                if (!prev.contains(resource.toString())) {
                    result.add(resource);
                }
            }
        } catch (final IOException e) {
            throw new I18nConfigurationException("Error loading I18N configuration", e);
        }
        if (result.isEmpty()) {
            return null;
        } else if (result.size() == 1) {
            return result.get(0);
        } else {
            throw new I18nConfigurationException(
                    "Error loading I18N configuration: ClassLoader" + cl +
                    " contain multiple I18N configuration resources: " + result);
        }
    }
}