package io.eguan.configuration;
/*
* #%L
* Project eguan
* %%
* Copyright (C) 2012 - 2017 Oodrive
* %%
* Licensed 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.
* #L%
*/
import io.eguan.configuration.ValidationError.ErrorType;
import io.eguan.utils.Files;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.base.Strings;
/**
* Complete implementation of the {@link Configuration} interface managing {@link AbstractConfigKey}s through
* {@link AbstractConfigurationContext}s.
*
* @author oodrive
* @author pwehrle
* @author llambert
*
*/
public final class MetaConfiguration implements Configuration<AbstractConfigKey> {
private static final Logger LOGGER = LoggerFactory.getLogger(MetaConfiguration.class);
/**
* The property key pattern to check the entries found in configuration sources against.
*
* This pattern is constructed to depend on the patterns defined in {@link AbstractConfigurationContext} and
* {@link AbstractConfigKey}.
*
* @see AbstractConfigurationContext#NAME_PATTERN
* @see AbstractConfigKey#NAME_PATTERN
*/
private static final Pattern KEY_PATTERN = Pattern.compile("^(" + AbstractConfigurationContext.NAME_PATTERN + "\\."
+ AbstractConfigKey.NAME_PATTERN + ")\\s*=.*");
/**
* Creates a {@link MetaConfiguration} reading {@link Properties} from an {@link InputStream} and validates against
* at least one {@link ConfigurationContext}.
*
* @param inputStream
* a non-{@code null} InputStream from which to load the configuration. The stream is explicitly closed
* when loading is finished.
* @param configurationContexts
* the ConfigurationContexts to include for managing the keys and values of the configuration
* @return a functional and valid {@link MetaConfiguration} instance
* @throws ConfigValidationException
* <ul>
* <li>if the configuration loaded from the {@link InputStream} is invalid or</li>
* <li>a collision between ConfigurationContext names is detected</li>
* </ul>
* @throws IOException
* if reading the {@link InputStream} fails
* @throws NullPointerException
* if the {@link InputStream} is {@code null}, among others
* @throws IllegalArgumentException
* if
* <ul>
* <li>not at least one {@link ConfigurationContext} is provided ({@code null} is not accepted) or</li>
* <li>a value provided in the configuration fails to parse</li>
* </ul>
*/
public static final MetaConfiguration newConfiguration(@Nonnull final InputStream inputStream,
final AbstractConfigurationContext... configurationContexts) throws IOException, ConfigValidationException,
NullPointerException, IllegalArgumentException {
if (inputStream == null) {
throw new NullPointerException("InputStream to read from is null");
}
if (configurationContexts == null) {
throw new IllegalArgumentException("Configuration context list is null");
}
final List<AbstractConfigurationContext> configContextList = new ArrayList<AbstractConfigurationContext>(
Arrays.asList(configurationContexts));
if (configContextList.contains(null)) {
LOGGER.warn("Configuration context list contains null; list='{}'", Arrays.toString(configurationContexts));
configContextList.remove(null);
}
if (configContextList.size() < 1) {
throw new IllegalArgumentException("No configuration context given");
}
final List<ValidationError> globalReport = checkConfigurationContexts(configContextList);
globalReport.addAll(checkForDuplicateConfigKeys(configContextList));
globalReport.addAll(checkForNameCollisions(configContextList));
if (!globalReport.isEmpty()) {
throw new ConfigValidationException(globalReport);
}
// final Properties newProperties = checkAndLoadProperties(inputStream);
final Properties newProperties = new Properties();
globalReport.addAll(checkInputAndLoadProperties(inputStream, newProperties));
final MetaConfiguration result = new MetaConfiguration(newProperties, configContextList);
// validates the resulting state
for (final AbstractConfigurationContext currContext : configContextList) {
globalReport.addAll(currContext.validateConfiguration(result));
}
if (!globalReport.isEmpty()) {
throw new ConfigValidationException(globalReport);
}
return result;
}
/**
* Load a {@link Properties} file to create a new {@link MetaConfiguration}.
*
* @param configurationFile
* file to read
* @param configurationContexts
* the ConfigurationContexts to include for managing the keys and values of the configuration
* @return a non-<code>null</code> {@link MetaConfiguration}
* @throws IOException
* if reading the file fails
* @throws ConfigValidationException
* if the configuration contained in the file is invalid
* @throws NullPointerException
* if the given {@link File} is <code>null</code>
* @throws IllegalArgumentException
* if
* <ul>
* <li>not at least one {@link ConfigurationContext} is provided ({@code null} is not accepted) or</li>
* <li>a value provided in the configuration fails to parse</li>
* </ul>
* @see #newConfiguration(InputStream, AbstractConfigurationContext...)
*/
public static final MetaConfiguration newConfiguration(@Nonnull final File configurationFile,
final AbstractConfigurationContext... configurationContexts) throws IOException, ConfigValidationException,
NullPointerException, IllegalArgumentException {
// new FileInputStream throws NPE if the File is null
try (FileInputStream fis = new FileInputStream(configurationFile)) {
return MetaConfiguration.newConfiguration(fis, configurationContexts);
}
}
/**
* The properties object holding the complete configuration.
*/
private final Properties configProperties;
/**
* Map to hold the managed part of the configuration, with keys and values of the corresponding type.
*/
private final Map<AbstractConfigKey, Object> typedConfiguration;
/**
* Properties holding all key-value pairs not explicitly managed by this configuration.
*/
private final Properties unmanagedProperties;
/**
* Set of {@link ConfigurationContext} managed by this configuration.
*/
private final Set<AbstractConfigurationContext> configContexts;
/**
* Immutable list of managed {@link ConfigKey}s.
*/
private final Collection<AbstractConfigKey> configKeys;
/**
* The comment string to insert when storing the configuration.
*
* @see MetaConfiguration#storeConfiguration(OutputStream)
* @see MetaConfiguration#storeCompleteConfiguration(OutputStream)
*/
private final String propertiesFileComment;
/**
* Private constructor only to be called by
* {@link MetaConfiguration#newConfiguration(InputStream, ConfigurationContext...)}.
*
* @param properties
* the {@link Properties} object defining the state of this configuration
* @param configContextList
* a list of {@link AbstractConfigurationContext}, excluding {@code null}
* @throws IllegalArgumentException
* if one of the parameter values fails to parse correctly
*/
private MetaConfiguration(final Properties properties, final List<AbstractConfigurationContext> configContextList)
throws IllegalArgumentException {
// creates context set without extra verification, as this has already happened in the factory method
configContexts = Collections.unmodifiableSet(new HashSet<AbstractConfigurationContext>(configContextList));
// reads the configuration from the input stream
configProperties = properties;
final ArrayList<AbstractConfigKey> configKeyList = new ArrayList<AbstractConfigKey>();
for (final AbstractConfigurationContext currContext : configContexts) {
configKeyList.addAll(currContext.getConfigKeys());
}
configKeys = Collections.unmodifiableList(configKeyList);
// initializes the typed configuration map
typedConfiguration = new HashMap<AbstractConfigKey, Object>(configKeys.size());
// initializes the unmanaged Properties map to include mappings for all keys found
unmanagedProperties = new Properties();
unmanagedProperties.putAll(configProperties);
/*
* iterates over all managed keys to the typed map adding them to the typed map and removing them from the
* unmanaged map
*/
for (final AbstractConfigurationContext currContext : configContexts) {
for (final AbstractConfigKey currKey : currContext.getConfigKeys()) {
final String propertyKey = currContext.getPropertyKey(currKey);
final String stringValue = configProperties.getProperty(propertyKey);
unmanagedProperties.remove(propertyKey);
if (stringValue == null) {
typedConfiguration.put(currKey, currKey.getDefaultValue());
}
else {
typedConfiguration.put(currKey, currKey.parseValue(stringValue.trim()));
}
}
}
// initializes the comment to include in saved properties files
propertiesFileComment = String.format(" %s with configuration contexts: %s",
MetaConfiguration.class.getSimpleName(), Arrays.toString(configContexts.toArray()));
}
@Override
public final Collection<AbstractConfigKey> getConfigKeys() {
return configKeys;
}
@Override
public final Properties getUnmanagedKeys() {
// returns the only defensive copy guaranteed to function
final Properties result = new Properties();
result.putAll(unmanagedProperties);
return result;
}
@Override
public MetaConfiguration copyAndAlterConfiguration(final Map<AbstractConfigKey, Object> newKeyValueMap)
throws ConfigValidationException, NullPointerException, IllegalArgumentException, IOException {
final Properties newProps = new Properties();
newProps.putAll(configProperties);
for (final AbstractConfigKey currKey : newKeyValueMap.keySet()) {
final AbstractConfigurationContext context = getContextForKey(currKey);
if (context == null) {
throw new IllegalArgumentException("Key not in managed context; key=" + currKey);
}
newProps.setProperty(context.getPropertyKey(currKey), currKey.valueToString(newKeyValueMap.get(currKey)));
}
final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
newProps.store(outputStream, propertiesFileComment);
return newConfiguration(new ByteArrayInputStream(outputStream.toByteArray()),
configContexts.toArray(new AbstractConfigurationContext[configContexts.size()]));
}
@Override
public final void storeConfiguration(final OutputStream outputStream) throws IOException {
configProperties.store(outputStream, propertiesFileComment);
}
@Override
public final void storeCompleteConfiguration(final OutputStream outputStream) throws IOException {
getCompleteConfigurationAsProperties().store(outputStream, propertiesFileComment);
}
@Override
public Properties getCompleteConfigurationAsProperties() {
final Properties result = new Properties();
result.putAll(unmanagedProperties);
for (final AbstractConfigurationContext currContext : configContexts) {
for (final AbstractConfigKey currKey : currContext.getConfigKeys()) {
final Object rawValue = getValue(currKey);
result.setProperty(currContext.getPropertyKey(currKey), currKey.valueToString(rawValue));
}
}
return result;
}
@Override
public final void storeConfiguration(final File configuration, final File prevConfiguration, final boolean complete)
throws IOException {
// Remove previous configuration, what ever it is
Files.deleteRecursive(prevConfiguration.toPath());
// Save previous configuration. Just rename, even if it's not a regular file
final boolean renamed;
if (configuration.exists()) {
if (!configuration.renameTo(prevConfiguration)) {
throw new IOException("Failed to rename '" + configuration + "'");
}
renamed = true;
}
else {
renamed = false;
}
// Write new configuration
boolean done = false;
try {
try (FileOutputStream fos = new FileOutputStream(configuration)) {
if (complete) {
storeCompleteConfiguration(fos);
}
else {
storeConfiguration(fos);
}
}
done = true;
}
finally {
// Restore previous configuration on error
if (!done) {
configuration.delete();
if (renamed) {
if (!prevConfiguration.renameTo(configuration)) {
throw new IOException("Failed to rename '" + prevConfiguration + "'");
}
}
}
}
}
/**
* Gets the untyped value associated with the given key for this {@link Configuration}.
*
* This method gets the raw value directly from the underlying text representation, so there is no guarantee as to
* the validity or type of the returned value unless the given key is part of the list returned by
* {@link #getConfigKeys()}.
*
* @param key
* the requested {@link ConfigKey key}
* @return the value associated with the key, null if there is no value in this {@link Configuration}
* @see #getConfigKeys()
*/
protected final Object getValue(final AbstractConfigKey key) {
return typedConfiguration.get(key);
}
/**
* Checks for invalid {@link AbstractConfigurationContext#getName() names} and {@code null} or badly named keys in
* {@link AbstractConfigurationContext}s.
*
* As lists can contain {@code null} or the same object multiple times, this method does only compare different,
* non-{@code null} contexts if they point to the same object.
*
* @param configContextList
* the list of configurations to check, may contain the same element multiple times
* @return a report stating all errors encountered
*/
private static List<ValidationError> checkConfigurationContexts(
final List<AbstractConfigurationContext> configContextList) {
final ArrayList<ValidationError> report = new ArrayList<ValidationError>();
for (final AbstractConfigurationContext currContext : configContextList) {
// checks the context name
final String contextName = currContext.getName();
if (!AbstractConfigurationContext.isNameValid(contextName)) {
report.add(new ValidationError(ErrorType.CONTEXT_NAME, currContext, null, null,
"invalid context name; context=" + currContext + " name=" + contextName));
}
final Collection<AbstractConfigKey> currKeys = currContext.getConfigKeys();
// checks for null keys
if (currKeys.contains(null)) {
report.add(new ValidationError(ErrorType.CONTEXT_KEYS, currContext, null, null,
"key list contains null"));
}
// checks key names
for (final AbstractConfigKey currKey : currKeys) {
// skips null keys
if (currKey == null) {
continue;
}
final String keyName = currKey.getName();
if (Strings.isNullOrEmpty(keyName) || !AbstractConfigKey.isNameValid(keyName)) {
report.add(new ValidationError(ErrorType.KEYS_NAME, currContext, currKey, null,
"invalid key name; key=" + currKey + " name=" + keyName));
}
}
}
return report;
}
/**
* Checks for duplicate {@link ConfigKey keys} in the provided list of configuration {@link ConfigurationContext}s.
*
* As lists can contain {@code null} or the same object multiple times, this method does only compare different,
* non-{@code null} contexts if they point to the same object.
*
* @param configContextList
* the list of configurations to check, may contain the same element multiple times
* @return a report stating all duplicate config keys with a message stating the two {@link ConfigurationContext} in
* which it was found
*/
private static List<ValidationError> checkForDuplicateConfigKeys(
final List<AbstractConfigurationContext> configContextList) {
final ArrayList<ValidationError> report = new ArrayList<ValidationError>();
final ArrayList<AbstractConfigKey> duplicateList = new ArrayList<AbstractConfigKey>();
for (final AbstractConfigurationContext firstContext : configContextList) {
final Collection<AbstractConfigKey> firstKeys = firstContext.getConfigKeys();
for (final AbstractConfigurationContext secondContext : configContextList) {
if (firstContext == secondContext) {
continue;
}
// checks for disjoint configuration key lists
final HashSet<AbstractConfigKey> intersection = new HashSet<AbstractConfigKey>(
secondContext.getConfigKeys());
intersection.retainAll(firstKeys);
if (intersection.isEmpty() || duplicateList.containsAll(intersection)) {
continue;
}
duplicateList.addAll(intersection);
final AbstractConfigurationContext[] contexts = new AbstractConfigurationContext[] { firstContext,
secondContext };
final AbstractConfigKey[] keys = new AbstractConfigKey[intersection.size()];
report.add(new ValidationError(ErrorType.CONTEXT_KEYS, contexts, intersection.toArray(keys), null,
"keys exist in both contexts"));
}
}
return report;
}
/**
* Checks for collisions between {@link ConfigurationContext context} {@link AbstractConfigurationContext#getName()
* names}.
*
* @param configContextList
* the list of configurations to check, may contain the same element multiple times
* @return a report stating all config keys affected by context name collisions with a message stating the two
* {@link ConfigurationContext} in which it was found
*/
private static List<ValidationError> checkForNameCollisions(
final List<AbstractConfigurationContext> configContextList) {
final ArrayList<ValidationError> report = new ArrayList<ValidationError>();
for (final AbstractConfigurationContext firstContext : configContextList) {
final String firstName = firstContext.getName();
for (final AbstractConfigurationContext secondContext : configContextList) {
if (firstContext == secondContext) {
continue;
}
final String secondName = secondContext.getName();
if (firstName.startsWith(secondName)) {
final String errMsg = String.format(
"name collision between context=%s, name=%s and context=%s, name=%s", firstContext,
firstName, secondContext, secondName);
report.add(new ValidationError(ErrorType.CONTEXT_NAME, firstContext, null, null, errMsg));
}
}
}
return report;
}
/**
* Checks the input stream for duplicate key entries while loading the content into the provided {@link Properties}
* instance.
*
* @param inputStream
* the {@link InputStream} to load from
* @param targetProperties
* the {@link Properties} to load to
* @return a report of {@link ValidationError}s relative to duplicate entries
* @throws IOException
* if any of the I/O operations fail
*/
private static List<ValidationError> checkInputAndLoadProperties(final InputStream inputStream,
final Properties targetProperties) throws IOException {
final ArrayList<ValidationError> result = new ArrayList<ValidationError>();
final ArrayList<String> keyList = new ArrayList<String>();
final String separator = System.getProperty("line.separator");
final StringBuilder writer = new StringBuilder();
/*
* The following construction causes a massive memory leak if the inputStream throws an IOException upon being
* closed. In consequence the InputStream must be closed explicitly separately in the finally clause.
*/
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
String line;
while ((line = reader.readLine()) != null) {
final Matcher match = KEY_PATTERN.matcher(line);
if (match.matches()) {
keyList.add(match.group(1));
}
writer.append(line);
writer.append(separator);
}
}
finally {
inputStream.close();
}
final ByteArrayInputStream newInputStream = new ByteArrayInputStream(writer.toString().getBytes());
targetProperties.load(newInputStream);
final AbstractConfigurationContext context = null;
for (final String currKey : targetProperties.stringPropertyNames()) {
final int keyFreq = Collections.frequency(keyList, currKey);
if (keyFreq > 1) {
result.add(new ValidationError(ErrorType.KEYS_NAME, context, null, null, String.format(
"key='%s' appears %d times in input", currKey, Integer.valueOf(keyFreq))));
}
}
return result;
}
/**
* Searches the {@link AbstractConfigurationContext} instance for a given key.
*
* @param key
* the {@link AbstractConfigKey} for which to search
* @return the first {@link AbstractConfigurationContext} registered that manages the given key, <code>null</code>
* if none is found
*/
private AbstractConfigurationContext getContextForKey(final AbstractConfigKey key) {
for (final AbstractConfigurationContext currContext : this.configContexts) {
if (currContext.getConfigKeys().contains(key)) {
return currContext;
}
}
return null;
}
}