hm.binkley.util.XProperties.java Source code

Java tutorial

Introduction

Here is the source code for hm.binkley.util.XProperties.java

Source

/*
 * This is free and unencumbered software released into the public domain.
 *
 * Please see https://github.com/binkley/binkley/blob/master/LICENSE.md.
 */

package hm.binkley.util;

import hm.binkley.util.XPropsConverter.Conversion;
import lombok.NonNull;
import org.apache.commons.lang3.text.StrLookup;
import org.apache.commons.lang3.text.StrSubstitutor;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.BufferedReader;
import java.io.CharArrayReader;
import java.io.CharArrayWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URI;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static java.lang.String.format;
import static java.util.Arrays.asList;
import static java.util.regex.Pattern.compile;

/**
 * {@code XProperties} is a {@code java.util.Properties} supporting inclusion of
 * other properties files, parameter substition and typed return values.  Key
 * order is preserved; keys from included properties are prior to those from the
 * current properties.
 * <p>
 * Keys are restricted to {@code String}s.
 * <p>
 * Loading processes lines of the form: <pre>
 * #include <var>Spring style resource path</var></pre> for inclusion.  Looks
 * up <var>resource path</var> with a {@link ResourcePatternResolver}; loading
 * found resources in turn. Similarly processes resource recursively for further
 * inclusion. Includes multiple resources separated by comma (",") in the same
 * "#include" statement.
 * <p>
 * Substitutes in resource paths of the
 * form: <pre>
 * #include ${<var>variable</var>}</pre> or portions thereof.  Looks up
 * <var>variable</var> in the current properties, and if not found, in the
 * system properties.  If found, replaces the text with the variable value,
 * including "${" and trailing "}".
 * <p>
 * Use forward slashes ("/") only for path separators; <strong>do not</strong>
 * use back slashes ("\").
 * <p>
 * Examples: <table><tr><th>Text</th> <th>Result</th></tr> <tr><td>{@code
 * #include foo/file.properties}</td> <td>Includes {@code foo.properties} found
 * in the classpath</td></tr> <tr><td>{@code #include foo.properties,
 * bar.properties}</td> <td>Includes both {@code foo.properties} and {@code
 * bar.properties}</td></tr> <tr><td>{@code #include file:/var/tmp/${user.name
 * }/foo.properties}</td> <td>Includes {@code foo.properties} found in a
 * directory named after the current user</td></tr> <tr><td>{@code #include
 * classpath*:**&#47;foo.properties}</td> <td>Includes all {@code
 * foo.properties}</tr> files found subdirectories of the classpath</td></tr>
 * </table>
 * <p>
 * Note {@code equals} and {@code hashCode} are from {@code Properties}, not
 * overriden.  Generally JDK properties should not be compared or used as map
 * keys.
 *
 * @todo Implement defaults
 * @todo Converter assumes cacheable keys; is this correct?
 * @todo Inclusion requires "#include" in the first column - reasonable?
 * @see PathMatchingResourcePatternResolver
 * @see StrSubstitutor
 * @see XPropsConverter
 * @see #load(Reader) loading properties with inclusions
 * @see #load(InputStream) loading properties with inclusions
 * @see #getProperty(String) getting properties with substitution
 */
public class XProperties extends OrderedProperties {
    private static final Pattern include = compile("^#include\\s+(.*)\\s*$");
    private static final Pattern comma = compile("\\s*,\\s*");
    private static final Pattern colon = compile(":");

    private final Set<URI> included = new LinkedHashSet<>();
    private final XPropsConverter converter = new XPropsConverter();
    private final Map<Key, Object> converted = new ConcurrentHashMap<>();

    private final StrSubstitutor substitutor = new StrSubstitutor(new FindValue());

    {
        substitutor.setEnableSubstitutionInVariables(true);
    }

    /**
     * Creates a new {@code XProperties} for the given <var>absolutePath</var>
     * found in the classpath.
     *
     * @param absolutePath the absolute path to search on the classpath, never
     * missing
     *
     * @throws IOException if <var>absolutePath</var> cannot be loaded
     */
    @Nonnull
    public static XProperties from(@Nonnull @NonNull final String absolutePath) throws IOException {
        final Resource resource = new PathMatchingResourcePatternResolver().getResource(absolutePath);
        try (final InputStream in = resource.getInputStream()) {
            final XProperties xprops = new XProperties();
            xprops.included.add(resource.getURI());
            xprops.load(in);
            return xprops;
        }
    }

    /**
     * Constructs a new, default {@code XProperties} with no properties.
     */
    public XProperties() {
    }

    /**
     * Constructs a new {@code XProperties} containing the given
     * <var>initial</var> properties.
     *
     * @param initial the initial property pairs, never missing
     */
    public XProperties(@Nonnull final Map<String, String> initial) {
        putAll(initial);
    }

    /**
     * Registers the given property converter with key <var>prefix</var> and
     * typed-value <var>factory</var>.
     *
     * @param prefix the alias prefix, never missing
     * @param factory the factory, never missing
     *
     * @see XPropsConverter#register(String, Conversion)
     */
    public void register(@Nonnull final String prefix, @Nonnull final Conversion<?, ? extends Exception> factory) {
        converter.register(prefix, factory);
    }

    /**
     * Note {@code XProperties} description for additional features over plain
     * properties loading. {@inheritDoc}
     *
     * @throws IOException if the properties cannot be loaded or if included
     * resources cannot be read
     */
    @Override
    public synchronized void load(@Nonnull final Reader reader) throws IOException {
        final ResourcePatternResolver loader = new PathMatchingResourcePatternResolver();
        try (final CharArrayWriter writer = new CharArrayWriter()) {
            try (final BufferedReader lines = new BufferedReader(reader)) {
                for (String line = lines.readLine(); null != line; line = lines.readLine()) {
                    writer.append(line).append('\n');
                    final Matcher matcher = include.matcher(line);
                    if (matcher.matches())
                        for (final String x : comma.split(substitutor.replace(matcher.group(1))))
                            for (final Resource resource : loader.getResources(x)) {
                                final URI uri = resource.getURI();
                                if (!included.add(uri))
                                    throw new RecursiveIncludeException(uri, included);
                                try (final InputStream in = resource.getInputStream()) {
                                    load(in);
                                }
                            }
                }
            }

            super.load(new CharArrayReader(writer.toCharArray()));
        }
        included.clear();
    }

    /**
     * Note {@code XProperties} description for additional features over plain
     * properties loading. {@inheritDoc}
     *
     * @throws IOException if the properties cannot be loaded or if included
     * resources cannot be read
     */
    @Override
    public synchronized void load(final InputStream inStream) throws IOException {
        load(new InputStreamReader(inStream, Charset.forName("UTF-8")));
    }

    /**
     * {@inheritDoc} <p/>
     * Substitutes in property values sequences of the form: <pre>
     * ${<var>variable</var>}</pre>.  Looks up <var>variable</var> in the
     * current properties, and if not found, in the system properties.  If
     * found, replaces the text with the variable value, including "${" and
     * trailing "}".
     *
     * @throws MissingPropertyException if substitution refers to a missing
     * property
     * @see StrSubstitutor
     */
    @Nullable
    @Override
    public String getProperty(final String key) {
        final String value = super.getProperty(key);
        if (null == value)
            return null;
        return substitutor.replace(value);
    }

    /**
     * {@inheritDoc} <p/>
     * Substitutes in string values sequences of the form: <pre>
     * ${<var>variable</var>}</pre> for substition.  Looks up
     * <var>variable</var> in the current properties, and if not found, in the
     * system properties.  If found, replaces the text with the variable value,
     * including "${" and trailing "}".
     *
     * @see StrSubstitutor
     */
    @Nullable
    @Override
    public synchronized Object get(final Object key) {
        final Object value = super.get(key);
        if (null == value || !(value instanceof String))
            return value;
        return substitutor.replace((String) value);
    }

    /**
     * Gets a typed property value.  <strong>Responsibility is the
     * caller's</strong> to assign the return to a correct type; failure to do
     * so will cause a {@code ClassCastException} at run-time.
     * <p>
     * Typed keys are of the form: {@code <var>type</var>:<var>key</var>}.  The
     * <var>key</var> is the same key as {@link System#getProperty(String)
     * System.getProperty}.  See {@link XPropsConverter#register(String,
     * Conversion) register} for built-in <var>type</var> key prefixes.
     * <p>
     * Examples: <table><tr><th>Code</th> <th>Comment</th></tr> <tr><td>{@code
     * Integer foo = xprops.getObject("int:foo");}</td> <td>Gets the "foo"
     * property as an possibly {@code null} integer</td></tr> <tr><td>{@code int
     * foo = xprops.getObject("int:foo");}</td> <td>Gets the "foo" property as a
     * primitive integer, throwing {@code NullPointerException} if
     * missing</td></tr> <tr><td>{@code Long foo = xprops.getObject
     * ("long:foo");}</td> <td>Gets the "foo" property as an possibly {@code
     * null} long; this is the same property as the previous
     * examples</td></tr></table>
     *
     * @param key the type-prefixed key, never missing
     * @param <T> the value type
     *
     * @return the type property value, possibly missing
     *
     * @see #getObjectOrDefault(String, Object)
     * @see XPropsConverter#register(String, Conversion)
     */
    @Nullable
    public <T> T getObject(@Nonnull final String key) {
        return getObjectOrDefault(key, null);
    }

    /**
     * Gets the typed value of the given property <var>key</var>.
     *
     * @param key the type-prefixed key, never missing
     * @param defaultValue the fallback value if <var>key</var> is not present
     * @param <T> the value type
     *
     * @return the typed value for <var>key</var>
     */
    @Nullable
    @SuppressWarnings("unchecked")
    public <T> T getObjectOrDefault(@Nonnull final String key, @Nullable final T defaultValue) {
        return (T) converted.computeIfAbsent(new Key(key, defaultValue), this::convert);
    }

    private Object convert(final Key key) {
        final String property = key.property;
        final String wholeValue = getProperty(property);
        if (null != wholeValue)
            return wholeValue; // Literal key match wins - assume T==String
        final String[] parts = colon.split(property, 2);
        final String value = getProperty(parts[1]);
        if (null == value)
            return key.fallback;
        try {
            return converter.convert(parts[0], value);
        } catch (final Exception e) {
            throw new FailedConversionException(property, value, e.getCause());
        }
    }

    private final class FindValue extends StrLookup {
        @Override
        public String lookup(final String key) {
            return asList((Function<String, String>) XProperties.this::getProperty, System::getProperty,
                    System::getenv).stream().map(f -> f.apply(key)).filter(v -> null != v).findFirst()
                            .orElseThrow(() -> new MissingPropertyException(key));
        }
    }

    /**
     * Thrown if a set of x-properties recursively includes itself by incusion
     * of another set of properties.
     */
    public static final class RecursiveIncludeException extends RuntimeException {
        /**
         * Constructs a new {@code RecursiveIncludeException} for the given
         * parameters.
         *
         * @param duplicate the recursive inclusion, never missing
         * @param included the history of included property sets, never missing
         */
        public RecursiveIncludeException(@Nonnull final URI duplicate, @Nonnull final Collection<URI> included) {
            super(message(duplicate, included));
        }

        private static String message(final URI duplicate, final Collection<URI> included) {
            final List<URI> x = new ArrayList<>(included.size() + 1);
            x.addAll(included);
            x.add(duplicate);
            return x.toString();
        }
    }

    /** Thrown if a property value cannot be converted to a typed instance. */
    public static final class FailedConversionException extends RuntimeException {
        /**
         * Constructs a new {@code FailedConversionException} for the given
         * parameters.
         *
         * @param key the property key, never missng
         * @param value the unconverted property value, never missing
         * @param cause the factory exception, never missing
         */
        public FailedConversionException(@Nonnull final String key, @Nonnull final String value,
                @Nonnull final Throwable cause) {
            super(format("%s = %s", key, value), cause);
        }
    }

    /** Thrown if a property cannot be found. */
    public static class MissingPropertyException extends IllegalArgumentException {
        /**
         * Constructs a new {@code MissingPropertyException} for the given
         * property <var>key</var>.
         *
         * @param key the property key, never missing
         */
        public MissingPropertyException(@Nonnull final String key) {
            super(key);
        }
    }

    private static final class Key {
        private final String property;
        private final Object fallback;

        private Key(final String property, final Object fallback) {
            this.property = property;
            this.fallback = fallback;
        }
    }
}