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);
}
}
}