net.morimekta.config.ConfigSupplier Maven / Gradle / Ivy
/*
* 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.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.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.
*
* {@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!
* }
* }
* }
*
* See net.morimekta.tiny.server:tiny-server for
* details on the microservice server starter.
*
* @param The config type.
*/
public class ConfigSupplier implements Supplier, Closeable {
/**
* Create a config supplier.
*
* @param loader The config loader to use for the supplier.
*/
public ConfigSupplier(ConfigReader loader) {
this(loader, ConfigWatcher.FILE_WATCHER);
}
/**
* Create a config supplier.
*
* @param loader The config loader to use for the supplier.
* @param fileWatcherSupplier Supplier of file watcher. Mainly for testing.
*/
public ConfigSupplier(ConfigReader loader,
Supplier fileWatcherSupplier) {
this.reference = new AtomicReference<>();
this.changeListeners = new ArrayList<>();
this.eventListeners = new ArrayList<>();
this.loader = loader;
this.fileWatcherSupplier = fileWatcherSupplier;
this.fileEventListener = this::onFileEvent;
}
public ConfigSupplier addChangeListener(ConfigChangeListener listener) {
synchronized (mutex) {
changeListeners.removeIf(it -> it == listener);
changeListeners.add(listener);
}
return this;
}
public ConfigSupplier 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);
reference.set(requireNonNull(loader.readConfig(filePath), "loaded config == null"));
}
}
/**
* 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 {
config = requireNonNull(loader.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;
}
var old = reference.getAndSet(config);
if (!config.equals(old)) {
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);
}
}
// --- Supplier ---
/**
* 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 ---
/**
* Load config as YAML, just using available jackson modules.
*
* @param type The config entry class.
* @param The config entry type.
* @return The config supplier.
*/
public static ConfigSupplier yamlConfig(Class type) {
return yamlConfig(type, ObjectMapper::findAndRegisterModules);
}
/**
* Load config as YAML.
*
* @param type The config entry class.
* @param initMapper Initializer for the ObjectMapper to handle the config.
* @param The config entry type.
* @return The config supplier.
*/
public static ConfigSupplier yamlConfig(Class type,
Consumer initMapper) {
return new ConfigSupplier<>(new ConfigReader.YamlConfigReader<>(type, initMapper));
}
// --- Private ---
private static final Logger LOGGER = LoggerFactory.getLogger(ConfigSupplier.class);
private final Object mutex = new Object();
private final AtomicReference reference;
private final ArrayList> changeListeners;
private final ArrayList eventListeners;
private final ConfigReader loader;
private final Supplier 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 {
config = requireNonNull(loader.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) {
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> getChangeListeners() {
synchronized (mutex) {
return List.copyOf(changeListeners);
}
}
private List getEventListeners() {
synchronized (mutex) {
return List.copyOf(eventListeners);
}
}
}