ConfigSupplier.java

/*
 * Copyright 2023 Morimekta Utils Authors
 *
 * 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 net.morimekta.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import net.morimekta.config.ConfigEventListener.Status;
import net.morimekta.config.readers.ConfigReader;
import net.morimekta.config.readers.ConfigReaderSupplier;
import net.morimekta.config.readers.FixedConfigReaderSupplier;
import net.morimekta.config.readers.ProvidedConfigReaderSupplier;
import net.morimekta.config.readers.YamlConfigReader;
import net.morimekta.file.FileEvent;
import net.morimekta.file.FileEventListener;
import net.morimekta.file.FileUtil;
import net.morimekta.file.FileWatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.Closeable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Supplier;

import static java.util.Objects.requireNonNull;
import static net.morimekta.config.ConfigChangeType.CREATED;
import static net.morimekta.config.ConfigChangeType.MODIFIED;

/**
 * A wrapper around a config file to handle loading and parsing during
 * application setup.
 *
 * <pre>{@code
 * class MyApplication extends TinyApplication {
 *     private var config = ConfigSupplier.yamlConfig(MyConfig.class)
 *     {@literal@}Override
 *     public void initialize(ArgParser argParser, TinyApplicationContext.Builder context) {
 *         argParser.add(Option
 *             .optionLong("--config", "A config file", ValueParser.path(config::loadFromFile))
 *             .required())
 *     }
 *
 *     public void onStart(TinyApplicationContext context) {
 *         var myConfig = config.get();
 *         // you have a config!
 *     }
 * }
 * }</pre>
 * <p>
 * See <a href="https://morimekta.net/tiny-server"><code>net.morimekta.tiny.server:tiny-server</code></a> for
 * details on the microservice server starter.
 *
 * @param <ConfigType> The config type.
 */
public class ConfigSupplier<ConfigType> implements Supplier<ConfigType>, Closeable {
    /**
     * Create a config supplier.
     *
     * @param loader The config loader to use for the supplier.
     */
    public ConfigSupplier(ConfigReader<ConfigType> loader) {
        this(loader, ConfigWatcher.FILE_WATCHER);
    }

    /**
     * Create a config supplier.
     *
     * @param loader The config loader to use for the supplier.
     */
    public ConfigSupplier(ConfigReaderSupplier<ConfigType> loader) {
        this(loader,
             ConfigWatcher.FILE_WATCHER,
             Clock.systemUTC());
    }

    /**
     * Add a config change listener.
     *
     * @param listener The config change listener to add.
     * @return The config supplier.
     */
    public ConfigSupplier<ConfigType> addChangeListener(ConfigChangeListener<ConfigType> listener) {
        synchronized (mutex) {
            changeListeners.removeIf(it -> it == listener);
            changeListeners.add(listener);
        }
        return this;
    }

    /**
     * Add a config event change listener.
     *
     * @param listener The config event listener to add.
     * @return The config supplier.
     */
    public ConfigSupplier<ConfigType> addEventListener(ConfigEventListener listener) {
        synchronized (mutex) {
            eventListeners.removeIf(it -> it == listener);
            eventListeners.add(listener);
        }
        return this;
    }

    /**
     * Load config from file and store the result as the supplied config.
     *
     * @param filePath The file to load.
     * @throws IOException     If unable to read the file.
     * @throws ConfigException If unable to parse the file.
     */
    public void load(Path filePath) throws IOException, ConfigException {
        synchronized (mutex) {
            checkBeforeLoad(filePath);
            var reader = readerSupplier.getReaderFor(filePath);
            reference.set(requireNonNull(reader.readConfig(filePath), "loaded config == null"));
            fileReference.set(filePath);
            updatedAtReference.set(clock.instant());
        }
    }

    /**
     * Load config from file, store the result as the supplied config and start
     * monitoring the actual file for updates. Updates will then cause config
     * listeners to be triggered.
     *
     * @param filePath The file to load.
     * @throws IOException     If unable to read the file.
     * @throws ConfigException If unable to parse the file.
     */
    public void loadAndMonitor(Path filePath) throws ConfigException, IOException {
        synchronized (mutex) {
            checkBeforeLoad(filePath);
            fileWatcherSupplier.get().weakAddWatcher(filePath, fileEventListener);
            ConfigType config;
            try {
                var reader = readerSupplier.getReaderFor(filePath);
                config = requireNonNull(reader.readConfig(filePath), "loaded config == null");
                onConfigFileRead(FileEvent.CREATED, filePath, Status.OK);
            } catch (ConfigException e) {
                onConfigFileRead(FileEvent.CREATED, filePath, Status.PARSE_FAILED);
                throw e;
            } catch (IOException e) {
                onConfigFileRead(FileEvent.CREATED, filePath, Status.READ_FAILED);
                throw e;
            }
            reference.set(config);
            fileReference.set(filePath);
            onConfigChange(CREATED, filePath, config);
        }
    }

    /**
     * Load config from file and store the result as the supplied config. Protect
     * the caller from checked exceptions by wrapping them as matching unchecked
     * variants.
     *
     * @param filePath The file to load.
     */
    public void loadUnchecked(Path filePath) {
        try {
            load(filePath);
        } catch (ConfigException e) {
            throw e.asUncheckedException();
        } catch (IOException e) {
            throw new UncheckedIOException(e.getMessage(), e);
        }
    }

    /**
     * Load config from file, store the result as the supplied config and start
     * monitoring the actual file for updates. Updates will then cause config
     * listeners to be triggered. Protect the caller from checked exceptions by
     * wrapping them as matching unchecked variants.
     *
     * @param filePath The file to load.
     */
    public void loadAndMonitorUnchecked(Path filePath) {
        try {
            loadAndMonitor(filePath);
        } catch (ConfigException e) {
            throw e.asUncheckedException();
        } catch (IOException e) {
            throw new UncheckedIOException(e.getMessage(), e);
        }
    }

    /**
     * Get the loaded config file. For monitored config supplier, this is the
     * file that is watched.
     *
     * @return The loaded config file.
     */
    public Path getFile() {
        return fileReference.get();
    }

    /**
     * Get the timestamp of last successful update.
     *
     * @return The last updated timestamp.
     */
    public Instant getLastUpdatedAt() {
        return updatedAtReference.get();
    }

    // --- Supplier<ConfigType> ---

    /**
     * Get the current config content.
     *
     * @return The last loaded config.
     * @throws NullPointerException If no config has been loaded.
     */
    @Override
    public ConfigType get() {
        return Optional.ofNullable(reference.get())
                       .orElseThrow(() -> new NullPointerException("Config not loaded."));
    }

    // --- Closeable ---

    @Override
    public void close() {
        synchronized (mutex) {
            changeListeners.clear();
            eventListeners.clear();
            fileWatcherSupplier.get().removeWatcher(fileEventListener);
        }
    }

    // --- Object ---

    @Override
    public String toString() {
        return "ConfigSupplier{config=" + reference.get() + "}";
    }

    // --- Static ---

    /**
     * Make a config supplier, and figure out file reader by detecting file type
     * and using default reader for the type.
     *
     * @param type         The config entry class.
     * @param <ConfigType> The config entry type.
     * @return The config supplier.
     */
    public static <ConfigType> ConfigSupplier<ConfigType> config(
            Class<ConfigType> type) {
        return new ConfigSupplier<>(new ProvidedConfigReaderSupplier<>(type));
    }

    /**
     * Load a config file, and detect file type and using default reader for the type.
     *
     * @param type         The config entry class.
     * @param file         The config file to load.
     * @param <ConfigType> The config entry type.
     * @return The config supplier.
     * @throws ConfigException On config parse failures.
     * @throws IOException     On file read failures.
     */
    public static <ConfigType> ConfigSupplier<ConfigType> config(
            Class<ConfigType> type,
            Path file)
            throws ConfigException, IOException {
        var supplier = new ConfigSupplier<>(new ProvidedConfigReaderSupplier<>(type));
        supplier.load(file);
        return supplier;
    }

    /**
     * Load config as YAML, just using available jackson modules.
     *
     * @param type         The config entry class.
     * @param <ConfigType> The config entry type.
     * @return The config supplier.
     */
    public static <ConfigType> ConfigSupplier<ConfigType> yamlConfig(
            Class<ConfigType> type) {
        return new ConfigSupplier<>(new YamlConfigReader<>(type));
    }

    /**
     * Load config as YAML.
     *
     * @param type         The config entry class.
     * @param initMapper   Initializer for the ObjectMapper to handle the config.
     * @param <ConfigType> The config entry type.
     * @return The config supplier.
     */
    public static <ConfigType> ConfigSupplier<ConfigType> yamlConfig(
            Class<ConfigType> type,
            Consumer<ObjectMapper> initMapper) {
        return new ConfigSupplier<>(new YamlConfigReader<>(type, initMapper));
    }

    /**
     * Load config as YAML.
     *
     * @param type         The config entry class.
     * @param file         The config file to load.
     * @param initMapper   Initializer for the ObjectMapper to handle the config.
     * @param <ConfigType> The config entry type.
     * @return The config supplier.
     * @throws ConfigException On config parse failures.
     * @throws IOException     On file read failures.
     */
    public static <ConfigType> ConfigSupplier<ConfigType> yamlConfig(
            Class<ConfigType> type,
            Path file,
            Consumer<ObjectMapper> initMapper)
            throws ConfigException, IOException {
        var supplier = new ConfigSupplier<>(new YamlConfigReader<>(type, initMapper));
        supplier.load(file);
        return supplier;
    }

    // --- Protected ---

    /**
     * Create a config supplier.
     *
     * @param loader              The config loader to use for the supplier.
     * @param fileWatcherSupplier Supplier of file watcher. Available for testing.
     */
    protected ConfigSupplier(ConfigReader<ConfigType> loader,
                             Supplier<FileWatcher> fileWatcherSupplier) {
        this(new FixedConfigReaderSupplier<>(loader),
             fileWatcherSupplier,
             Clock.systemUTC());
    }

    /**
     * Create a config supplier.
     *
     * @param readerSupplier      The config loader to use for the supplier.
     * @param fileWatcherSupplier Supplier of file watcher. Available for testing.
     * @param clock               Clock to get update timestamp from.
     */
    protected ConfigSupplier(ConfigReaderSupplier<ConfigType> readerSupplier,
                             Supplier<FileWatcher> fileWatcherSupplier,
                             Clock clock) {
        this.clock = clock;
        this.reference = new AtomicReference<>();
        this.updatedAtReference = new AtomicReference<>();
        this.fileReference = new AtomicReference<>();
        this.changeListeners = new ArrayList<>();
        this.eventListeners = new ArrayList<>();
        this.readerSupplier = readerSupplier;
        this.fileWatcherSupplier = fileWatcherSupplier;
        this.fileEventListener = this::onFileEvent;
    }

    // --- Private ---

    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigSupplier.class);

    private final Object                                      mutex = new Object();
    private final Clock                                       clock;
    private final AtomicReference<ConfigType>                 reference;
    private final AtomicReference<Instant>                    updatedAtReference;
    private final AtomicReference<Path>                       fileReference;
    private final ArrayList<ConfigChangeListener<ConfigType>> changeListeners;
    private final ArrayList<ConfigEventListener>              eventListeners;
    private final ConfigReaderSupplier<ConfigType>            readerSupplier;
    private final Supplier<FileWatcher>                       fileWatcherSupplier;
    private final FileEventListener                           fileEventListener;

    private void checkBeforeLoad(Path filePath) throws IOException {
        if (reference.get() != null) {
            throw new IllegalStateException("Config already loaded.");
        }
        var canonical = FileUtil.readCanonicalPath(filePath);
        if (!Files.exists(canonical)) {
            throw new FileNotFoundException("No such config file " + filePath);
        }
        if (!Files.isRegularFile(canonical)) {
            throw new IOException("Config path " + filePath + " is not a regular file.");
        }
    }

    private void onFileEvent(Path file, FileEvent event) {
        if (event == FileEvent.DELETED) {
            if (Files.exists(file)) {
                // Ignore, same special case as with ConfigWatcher#onFileEventInternal
                return;
            }
            synchronized (mutex) {
                fileWatcherSupplier.get().removeWatcher(fileEventListener);
                onConfigFileRead(FileEvent.DELETED, file, Status.OK);
                onConfigChange(ConfigChangeType.DELETED, file, reference.get());
            }
            // NOTE: Keeping last loaded config, just stop listening.
        } else {
            if (!Files.exists(file)) {
                // Ignore, same special case as with ConfigWatcher#onFileEventInternal
                return;
            }
            synchronized (mutex) {
                ConfigType config;
                try {
                    var reader = readerSupplier.getReaderFor(file);
                    config = requireNonNull(reader.readConfig(file), "loaded config == null");
                    onConfigFileRead(FileEvent.MODIFIED, file, Status.OK);
                } catch (ConfigException e) {
                    LOGGER.error("Exception parsing config: {}", e.getMessage(), e);
                    onConfigFileRead(FileEvent.MODIFIED, file, Status.PARSE_FAILED);
                    return;
                } catch (IOException e) {
                    LOGGER.error("Exception reading config: {}", e.getMessage(), e);
                    onConfigFileRead(FileEvent.MODIFIED, file, Status.READ_FAILED);
                    return;
                }
                var old = reference.getAndSet(config);
                if (!config.equals(old)) {
                    onConfigChange(MODIFIED, file, config);
                }
            }
        }
    }

    private void onConfigFileRead(FileEvent type, Path file, Status status) {
        for (var el : getEventListeners()) {
            try {
                el.onConfigFileRead(type, file, status);
            } catch (Exception e) {
                LOGGER.error("Exception notifying on config read.", e);
            }
        }
    }

    private void onConfigFileUpdate(ConfigChangeType type, Path file, Status status) {
        for (var el : getEventListeners()) {
            try {
                el.onConfigFileUpdate(type, file, file.getFileName().toString(), status);
            } catch (Exception e) {
                LOGGER.error("Exception notifying on config update.", e);
            }
        }
    }

    private void onConfigChange(ConfigChangeType type, Path file, ConfigType config) {
        updatedAtReference.set(clock.instant());
        boolean error = false;
        for (var cl : getChangeListeners()) {
            try {
                cl.onConfigChange(type, config);
            } catch (Exception e) {
                error = true;
                LOGGER.error("Exception notifying updated config.", e);
            }
        }
        onConfigFileUpdate(type, file, error ? Status.ERROR : Status.OK);
    }

    private List<ConfigChangeListener<ConfigType>> getChangeListeners() {
        synchronized (mutex) {
            return List.copyOf(changeListeners);
        }
    }

    private List<ConfigEventListener> getEventListeners() {
        synchronized (mutex) {
            return List.copyOf(eventListeners);
        }
    }
}