/* * The MIT License * * Copyright 2013 Tim Boudreau. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package com.mastfrog.settings; import com.mastfrog.util.Checks; import com.mastfrog.util.ConfigurationError; import com.mastfrog.util.Streams; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.lang.ref.Reference; import java.lang.ref.WeakReference; import java.net.HttpURLConnection; import java.net.URISyntaxException; import java.net.URL; import java.security.ProtectionDomain; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.TimerTask; import java.util.logging.Level; import java.util.logging.Logger; /** * Builder for Settings. Allows multiple sources of settings to be layered * together. File and URL types of settings allow for periodic reloading. * <p/> * Sources for settings are added to the SettingsBuilder in LIFO order - the * last one to be added will be the first one to be queried in the resulting * Settings. <h2>Namespaces</h2> A "namespace" may be provided, which changes * the default files SettingsBuilder looks in, in the case you call * <code>addDefaultLocations()</code> or similar. * * @author Tim Boudreau */ public final class SettingsBuilder { /** * Path within JAR files to look for settings information */ public static final String DEFAULT_PATH = "META-INF/settings/"; /** * The default namespace, "defaults", which is used for settings which do * not explicitly have a namespace */ public static final String DEFAULT_NAMESPACE = "defaults"; /** * File extension for settings files <code>.properties</code> */ public static final String DEFAULT_EXTENSION = ".properties"; /** * Prefix used for settings files generated from annotations, * <code>generated-</code> */ public static final String GENERATED_PREFIX = "generated-"; private final List<PropertiesSource> all = new ArrayList<>(5); private final String namespace; public SettingsBuilder() { this.namespace = DEFAULT_NAMESPACE; } /** * Create a new settings builder which will read files which match the * passed namespace name - i.e. if you pass "foo" and then call * <code>addDefaultLocations</code>, you get a settings builder which will * look for <code>META-INF/settings/foo.properties</code> and * <code>META-INF/settings/generated-foo.properties</code> in all JARs on * the classpath. * * @param namespace The namespace. May not contain whitespace, colons, * commas, or path or file delimiter characters */ public SettingsBuilder(String namespace) { Checks.notNull("namespace", namespace); Checks.mayNotContain("namespace", namespace, ',', '/', '\\', ':', ';'); this.namespace = namespace; } /** * Create a new settings builder which will read files which match the * passed namespace name - i.e. if you pass "foo" and then call * <code>addDefaultLocations</code>, you get a settings builder which will * look for <code>META-INF/settings/foo.properties</code> and * <code>META-INF/settings/generated-foo.properties</code> in all JARs on * the classpath. * * @param namespace The namespace. May not contain whitespace, colons, * commas, or path or file delimiter characters */ public static SettingsBuilder forNamespace(String namespace) { Checks.notNull("namespace", namespace); Checks.notEmpty("namespace", namespace); return new SettingsBuilder(namespace); } public String getNamespace() { return namespace; } private String getGeneratedFilesLocation() { return DEFAULT_PATH + GENERATED_PREFIX + namespace + DEFAULT_EXTENSION; } private String getDefaultLocation() { return DEFAULT_PATH + namespace + DEFAULT_EXTENSION; } public SettingsBuilder add(SettingsBuilder sb) { this.all.addAll(sb.all); return this; } public SettingsBuilder add(String key, boolean value) { add(key, Boolean.toString(value)); return this; } public SettingsBuilder add(String key, int value) { add(key, Integer.toString(value)); return this; } /** * Add any properties file generated from the @Defaults annotation. * * @return this */ public SettingsBuilder addGeneratedDefaultsFromClasspath() { return add(getGeneratedFilesLocation()); } /** * Add any properties files on the classpath in the default location * (com/mastfrog/defaults.properties or the value of the system property * settings.location) * * @return this */ public SettingsBuilder addDefaultsFromClasspath() { return add(getDefaultLocation()); } /** * Add system properties * * @return this */ public SettingsBuilder addSystemProperties() { return add(new SystemPropertiesSource()); } /** * Look up settings files matching the pattern $NAMESPACE.properties in the * process working directory. * * @return this */ public SettingsBuilder addDefaultsFromProcessWorkingDir() { File f = new File(namespace.replace('/', '_').replace('\\', '_') + SettingsBuilder.DEFAULT_EXTENSION); if (f.exists()) { return add(f); } else { log("Not adding " + f.getAbsolutePath() + " to settings for " + "namespace " + namespace + " because it does not exist"); return this; } } /** * Add a file with the name of this namespace from the user home. E.g. for * namespace foo this will look for a file named * <code>~/foo.properties</code>. Note that if the namespace contains / or \ * these are replaced with _. * * @return */ public SettingsBuilder addDefaultsFromUserHome() { File home = new File(System.getProperty("user.home")); File file = new File(home, namespace.replace('/', '_').replace('\\', '_') + DEFAULT_EXTENSION); if (file.exists()) { return add(file); } else { log("Not adding " + file + " to settings for " + "namespace " + namespace + " because it does not exist"); return this; } } public SettingsBuilder addDefaultsFromEtc() { File home = new File("/opt/local/etc"); if (!home.exists()) { home = new File("/etc"); } if (home.exists()) { File file = new File(home, namespace.replace('/', '_').replace('\\', '_') + DEFAULT_EXTENSION); if (file.exists()) { return add(file); } else { log("Not adding " + file + " to settings for " + "namespace " + namespace + " because it does not exist"); } } return this; } /** * Add environment variables * * @return this */ public SettingsBuilder addEnv() { return add(new EnvPropertiesSource()); } /** * Add a single key and value * * @param key * @param value * @return */ public SettingsBuilder add(String key, String value) { Properties props = new Properties(); props.setProperty(key, value); return add(props); } /** * Add a properties file at a remote URL, for supplying remote configuration * * @param url The url * @param timeout The timeout for reloading * @return this */ public SettingsBuilder add(URL url, RefreshInterval timeout) { all.add(new UrlPropertiesSource(url, timeout)); return this; } /** * Add a properties file at a remote URL, for supplying remote configuration * * @param url The url * @return this */ public SettingsBuilder add(URL url) { all.add(new UrlPropertiesSource(url)); return this; } /** * Create a settings builder which will provide settings from the following * locations, last-first ($NAMESPACE is whatever namespace name was passed * to the constructor): <ol> <li>Environment variables</li> <li>System * properties</li> <li>All files named * /com/mastfrog/$NAMESPACE-defaults.properties on the classpath * (annotation-generated files in JARs); precedence is determined by * classpath order</li> <li>All files named * /com/mastfrog/$NAMESPACE.properties (hand-written files in JARs); * precedence is determined by classpath order</li> <li>Any file named * $NAMESPACE.properties in the user home dir</li> <li>Any file named * $NAMESPACE.properties in the process's working directory</li> </ol> * * @return A settings builder */ public SettingsBuilder addDefaultLocations() { return addEnv() .addSystemProperties() .addFilesystemAndClasspathLocations(); } public SettingsBuilder addFilesystemAndClasspathLocations() { return addGeneratedDefaultsFromClasspath() .addDefaultsFromClasspath() .addDefaultsFromEtc() .addDefaultsFromUserHome() .addDefaultsFromProcessWorkingDir(); } public SettingsBuilder addLocation(File directory) { if (directory.exists() && !directory.isDirectory()) { throw new IllegalArgumentException("Not a directory: " + directory); } File gen = new File(directory, GENERATED_PREFIX + namespace + DEFAULT_EXTENSION); add(gen); File user = new File(directory, namespace + DEFAULT_EXTENSION); return add(user); } public static SettingsBuilder create() { return createWithDefaults(DEFAULT_NAMESPACE); } public static SettingsBuilder createWithDefaults(String namespace) { Checks.notNull("namespace", namespace); return new SettingsBuilder(namespace).addDefaultLocations(); } /** * Create a settings builder which will provide settings from the following * locations, last-first: <ol> <li>Environment variables</li> <li>System * properties</li> <li>All files named * /com/mastfrog/generated-defaults.properties on the classpath * (annotation-generated files in JARs); precedence is determined by * classpath order</li> <li>All files named * /com/mastfrog/defaults.properties (hand-written files in JARs); * precedence is determined by classpath order</li> <li>Any file named * defaults.properties in the user home dir</li> <li>Any file named * defaults.properties in the process's working directory</li> </ol> * * @return A settings builder */ public static SettingsBuilder createDefault() { SettingsBuilder b = new SettingsBuilder() .addEnv() .addSystemProperties() .addGeneratedDefaultsFromClasspath() .addDefaultsFromClasspath() .addDefaultsFromEtc() .addDefaultsFromUserHome() .addDefaultsFromProcessWorkingDir(); return b; } /** * Add a location on the classpath to load from * * @param location * @return */ public SettingsBuilder add(String location) { if (isLog()) { log("Add cp " + location); } InputStream[] streams = Streams.locate(location); if (streams != null) { for (InputStream in : streams) { add(in); } } return this; } /** * Add a static properties object * * @param properties * @return */ public SettingsBuilder add(Properties properties) { all.add(new FixedPropertiesSource(properties)); return this; } /** * Add a properties source which may or may not refresh on a timeout. If its * timeout is non-zero, it will automatically have its properties re-gotten * every interval. * * @param src * @return */ public SettingsBuilder add(PropertiesSource src) { all.add(src); return this; } /** * Add an properties to be loaded from an input stream * * @param in * @return */ public SettingsBuilder add(InputStream in) { if (in != null) { add(new InputStreamSource(in)); } return this; } /** * Add a properties file. The file need not exist, but it helps if it does. * * @param file * @return */ public SettingsBuilder add(File file) { if (isLog()) { log("Add " + file); } add(new FileSource(file)); return this; } /** * Add a properties file. The file need not exist, but it helps if it does. * * @param file * @param reloadInterval A timeout. If the file did not exist at the * previous load, it will still be checked for again subsequently * @return */ public SettingsBuilder add(File file, RefreshInterval reloadInterval) { add(new FileSource(file, reloadInterval)); return this; } public SettingsBuilder add(Settings settings) { add(new SettingsSource(settings)); return this; } private boolean isLog() { return Boolean.getBoolean(SettingsBuilder.class.getName() + ".log"); } @SuppressWarnings("NP_ALWAYS_NULL") //WTF! Findbugs thinks System.out might be null private void log(String s) { if (isLog()) { System.out.println(s); } } private static class ShutdownRefreshTasks implements Runnable { Set<Bridge> bridges = new HashSet<>(); @Override public void run() { for (Iterator<Bridge> it = bridges.iterator(); it.hasNext();) { Bridge b = it.next(); try { b.cancel(); } finally { it.remove(); } } } } private final ShutdownRefreshTasks shutdownRunnable = new ShutdownRefreshTasks(); public final Runnable onShutdownRunnable() { return shutdownRunnable; } public Settings build() throws IOException { List<Settings> settings = new LinkedList<>(); List<PropertiesSource> all = new LinkedList<>(this.all); Collections.reverse(all); Set<Bridge> bridges = new HashSet<>(); log("BUILDING SETTINGS FOR NAMESPACE " + this.namespace + " FROM:"); for (Iterator<PropertiesSource> it = all.iterator(); it.hasNext();) { PropertiesSource src = it.next(); it.remove(); if (isLog()) { log(" " + src); } if (src instanceof SettingsSource) { settings.add(((SettingsSource) src).settings); } else { PropertiesSettings s = new PropertiesSettings(src + ""); Bridge bridge = new Bridge(src, s); shutdownRunnable.bridges.add(bridge); bridge.go(); bridges.add(bridge); src.interval.add(bridge); settings.add(s); } } LayeredSettings result = new LayeredSettings(namespace, Collections.unmodifiableList(settings)); //use a weak reference to ensure refresh stops when //all references to the settings have been garbage collected Reference<LayeredSettings> ref = new WeakReference<>(result); for (Bridge b : bridges) { b.ref = ref; } return result; } /** * Parse command line arguments ala <code>--foo</code> translating to a * setting of foo=true or <code>--foo 23</code> translating to a setting of * foo=23. * * @param args * @return */ public SettingsBuilder parseCommandLineArguments(String... args) { return parseCommandLineArguments(Collections.<Character, String>emptyMap(), args); } /** * Parse command line arguments ala <code>--foo</code> translating to a * setting of foo=true or <code>--foo 23</code> translating to a setting of * foo=23. * <p/> * The passed map can map individual characters - i.e. short arguments such * as making -f equivalent to --foo, or -fb equivalent to --foo --bar * * @param args a mapping of short to long args * @return this */ public SettingsBuilder parseCommandLineArguments(Map<Character, String> mapping, String... args) { if (args == null || args.length == 0) { return this; } String last = null; Properties p = new Properties(); for (String arg : args) { if (arg.startsWith("--") && arg.length() > 2 && arg.charAt(2) != '-') { if (last != null) { p.setProperty(last, "true"); } last = arg.substring(2); } else if (arg.length() >= 2 && arg.charAt(0) == '-' && arg.charAt(1) != '-') { String sub = arg.substring(1); for (char c : sub.toCharArray()) { String val = mapping.get(c); if (val != null) { if (last != null) { p.setProperty(last, "true"); } last = val; } else { throw new ConfigurationError("Unknown short arg " + c + " - known args are " + mapping.keySet()); } } } else { if (last != null) { p.setProperty(last, arg); last = null; } else { throw new ConfigurationError("Dangling argument " + arg); } } } if (last != null) { p.setProperty(last, "true"); } return p.isEmpty() ? this : add(p); } /** * Create a Settings which has a mutable, ephemeral layer which overrides * the rest * * @return A mutable settings object * @throws IOException If an error occurs loading any of the settings */ public MutableSettings buildMutableSettings() throws IOException { return new WritableSettings(namespace, build()); } /** * Assign the passed key to the path to a file or folder relative to the JAR * the passed class lives in, optionally not setting it if another value is * already set for it. * <p/> * This is useful to, for example, automatically locate a folder of html * files relative to the project during development. If it discovers that * the folder is named "target" or "build" it will go up a level. * <p/> * Note: If called from inside an application server, all bets are off as to * where this looks - that varies wildly by vendor. * * @param key The settings key that should map to the file path * @param jarClass A class whose JAR the path is relative to * @param relPath A relative path * @param onlyIfNotPresent If true and if a setting for <code>key</code> * exists, do nothing * @return this * @throws URISyntaxException * @throws IOException */ public SettingsBuilder addFolderRelativeToJAR(String key, Class<?> jarClass, String relPath, boolean onlyIfNotPresent) throws URISyntaxException, IOException { // Get the location this JAR is in on disk, to set up paths relative to it ProtectionDomain protectionDomain = jarClass.getProtectionDomain(); URL location = protectionDomain.getCodeSource().getLocation(); if (onlyIfNotPresent) { String explicitDir = build().getString(key); if (explicitDir != null) { return this; } } // See if an explicitly provided assets folder was passed to us; if // not we will look for an "assets" folder belonging to this application File codebaseDir = new File(location.toURI()).getParentFile(); if ("target".equals(codebaseDir.getName()) || "build".equals(codebaseDir.getName())) { codebaseDir = codebaseDir.getParentFile(); } File f = new File(codebaseDir, relPath); return add(key, f.getPath()); } private static class Bridge extends TimerTask implements Runnable { private final PropertiesSource src; private final PropertiesContainer container; private Reference<LayeredSettings> ref; Bridge(PropertiesSource src, PropertiesContainer container) { this.src = src; this.container = container; } void go() throws IOException { container.setDelegate(src.getProperties()); } @Override public void run() { try { if (ref != null && ref.get() == null) { cancel(); return; } go(); } catch (IOException ex) { Logger.getLogger(SettingsBuilder.class.getName()).log(Level.SEVERE, null, ex); } } } /** * Source for properties which may be reread on a timer */ public static abstract class PropertiesSource { private final com.mastfrog.settings.RefreshInterval interval; protected PropertiesSource() { this(RefreshInterval.NONE); } protected PropertiesSource(RefreshInterval interval) { this.interval = interval; } public abstract Properties getProperties() throws IOException; public final RefreshInterval getPollInterval() { return interval; } } private static final class FixedPropertiesSource extends PropertiesSource { private final Properties properties; FixedPropertiesSource(Properties properties) { this.properties = properties; } @Override public Properties getProperties() throws IOException { return properties; } public String toString() { return "FIXED: " + properties; } } private static final class InputStreamSource extends PropertiesSource { private final InputStream in; private volatile boolean done; private Properties result; InputStreamSource(InputStream in) { this.in = in; } @Override public Properties getProperties() throws IOException { if (done) { return this.result; } Properties result = new Properties(); try { result.load(in); } finally { done = true; in.close(); } return this.result = result; } public String toString() { return in + ""; } } private static final class FileSource extends PropertiesSource { private final File file; FileSource(File file) { this(file, SettingsRefreshInterval.FILES); } FileSource(File file, RefreshInterval interval) { super(interval); this.file = file; } @Override public Properties getProperties() throws IOException { Properties props = new Properties(); if (file.exists()) { try (InputStream in = new FileInputStream(file)) { props.load(in); } } return props; } public String toString() { return "File: " + file.getAbsolutePath(); } } private static final class SettingsSource extends PropertiesSource { private final Settings settings; SettingsSource(Settings settings) { this.settings = settings; } @Override public Properties getProperties() throws IOException { throw new UnsupportedOperationException("Should not be called"); } public String toString() { return "Settings " + settings; } } private static final class SystemPropertiesSource extends PropertiesSource { SystemPropertiesSource() { super(SettingsRefreshInterval.SYSTEM_PROPERTIES); } @Override public Properties getProperties() throws IOException { return System.getProperties(); } @Override public String toString() { return "System Properties"; } } private static final class EnvPropertiesSource extends PropertiesSource { EnvPropertiesSource() { super(RefreshInterval.NONE); } @Override public Properties getProperties() throws IOException { Properties p = new Properties(); p.putAll(System.getenv()); return p; } @Override public String toString() { return "Environment Variables"; } } private static final class UrlPropertiesSource extends PropertiesSource { private final URL url; private long last = 0; private String etag; private final Properties lastProperties = new Properties(); UrlPropertiesSource(URL url) { this(url, SettingsRefreshInterval.URLS); } UrlPropertiesSource(URL url, RefreshInterval timeout) { super(timeout); this.url = url; } @Override public Properties getProperties() throws IOException { HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setIfModifiedSince(last); connection.setConnectTimeout(20000); connection.setInstanceFollowRedirects(true); if (etag != null) { connection.setRequestProperty("If-None-Match", etag); } connection.connect(); int code = connection.getResponseCode(); if (code == 200) { last = connection.getLastModified(); try (InputStream in = connection.getInputStream()) { Properties p = new Properties(); p.load(in); synchronized (lastProperties) { lastProperties.clear(); lastProperties.putAll(p); } } } return lastProperties; } @Override public String toString() { return "URL: " + url; } } }