ApplicationPropertyLoader.java
package com.cloudforge.core.config;
import com.cloudforge.core.local.DeploymentTarget;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Loads {@code application.properties} then {@code application-local.properties}
* from the classpath, with later resources overriding earlier ones.
*
* <p>Resolution helpers also consult environment variables and JVM system
* properties so CI/local shells can override without editing files.</p>
*
* @since 3.3.0
*/
public final class ApplicationPropertyLoader {
private static final Logger LOG = Logger.getLogger(ApplicationPropertyLoader.class.getName());
private static final String[] RESOURCE_NAMES = {
"application.properties",
"application-local.properties"
};
private static volatile Properties cached;
/** Defaults to {@link System#getenv(String)}; tests may replace to isolate the shell. */
private static volatile Function<String, String> environment = System::getenv;
private ApplicationPropertyLoader() {
}
/** Test-only: override env lookup ({@code null} restores {@link System#getenv}). */
static void overrideEnvironmentForTests(Function<String, String> lookup) {
environment = lookup == null ? System::getenv : lookup;
}
/** Merged classpath properties (application then application-local). */
public static Properties load() {
Properties local = cached;
if (local != null) {
return local;
}
synchronized (ApplicationPropertyLoader.class) {
if (cached == null) {
cached = loadFresh();
}
return cached;
}
}
/** Clears the cache (tests). */
public static void clearCache() {
synchronized (ApplicationPropertyLoader.class) {
cached = null;
}
}
/** Clears cache and restores default env lookup (tests). */
static void resetForTests() {
clearCache();
overrideEnvironmentForTests(null);
}
/**
* Resolve a dotted property key with env / system / file precedence.
*
* <p>Order: env ({@code CFC_MANAGER_URL} for {@code cfc.manager.url}) →
* JVM {@code -Dkey} → classpath properties.</p>
*/
public static String resolve(String propertyKey) {
if (propertyKey == null || propertyKey.isBlank()) {
return null;
}
String envName = toEnvName(propertyKey);
String fromEnv = environment.apply(envName);
if (notBlank(fromEnv)) {
return fromEnv.trim();
}
String fromSys = System.getProperty(propertyKey);
if (notBlank(fromSys)) {
return fromSys.trim();
}
// Also accept uppercase env-style system property
String fromSysEnv = System.getProperty(envName);
if (notBlank(fromSysEnv)) {
return fromSysEnv.trim();
}
String fromFile = load().getProperty(propertyKey);
if (notBlank(fromFile)) {
return fromFile.trim();
}
return null;
}
/**
* Fill blank {@link DeploymentConfig} fields that declare {@link com.cloudforge.core.annotation.ConfigField#propertyKey()}.
*/
public static void applyPropertyDefaults(DeploymentConfig config) {
if (config == null) {
return;
}
for (java.lang.reflect.Field field : DeploymentConfig.class.getDeclaredFields()) {
if (!field.isAnnotationPresent(com.cloudforge.core.annotation.ConfigField.class)) {
continue;
}
ConfigFieldInfo info = ConfigFieldInfo.from(field);
if (info.propertyKey() == null || info.propertyKey().isBlank()) {
continue;
}
Object current = info.getValue(config);
if (current != null && (!(current instanceof String s) || !s.isBlank())) {
continue;
}
String resolved = resolve(info.propertyKey());
if (resolved == null) {
continue;
}
// A malformed value for one property (e.g. a non-numeric string for an int-typed
// field) shouldn't abort defaulting for every other field in the loop.
Object coerced;
try {
coerced = coerce(resolved, info.type());
} catch (NumberFormatException e) {
LOG.warning("Property '" + info.propertyKey() + "'='" + resolved
+ "' is not a valid " + info.type().getSimpleName() + " — skipping");
continue;
}
info.setValue(config, coerced);
}
}
static String toEnvName(String propertyKey) {
return propertyKey.trim()
.replace('.', '_')
.replace('-', '_')
.toUpperCase();
}
private static Properties loadFresh() {
Properties merged = new Properties();
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl == null) {
cl = ApplicationPropertyLoader.class.getClassLoader();
}
for (String name : RESOURCE_NAMES) {
List<Properties> loaded = loadAll(cl, name);
for (Properties p : loaded) {
merged.putAll(toMap(p));
}
}
return merged;
}
private static List<Properties> loadAll(ClassLoader cl, String name) {
List<Properties> list = new ArrayList<>();
try {
Enumeration<java.net.URL> urls = cl.getResources(name);
while (urls.hasMoreElements()) {
java.net.URL url = urls.nextElement();
Properties p = new Properties();
try (InputStream in = url.openStream();
InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
p.load(reader);
list.add(p);
LOG.fine("Loaded " + name + " from " + url);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed reading " + url, e);
}
}
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed enumerating " + name, e);
}
return list;
}
private static Map<String, String> toMap(Properties p) {
Map<String, String> map = new LinkedHashMap<>();
for (String name : p.stringPropertyNames()) {
map.put(name, p.getProperty(name));
}
return map;
}
private static boolean notBlank(String value) {
return value != null && !value.isBlank();
}
private static Object coerce(String value, Class<?> type) {
if (type == String.class) {
return value;
}
if (type == Boolean.class || type == boolean.class) {
return Boolean.parseBoolean(value);
}
if (type == Integer.class || type == int.class) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new NumberFormatException("'" + value + "' is not a valid Integer");
}
}
if (type == Long.class || type == long.class) {
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
throw new NumberFormatException("'" + value + "' is not a valid Long");
}
}
// DeploymentTarget's own case-insensitive parsing (blank -> AWS, garbage -> a real
// IllegalArgumentException) is the one already established elsewhere in this codebase for
// this exact type -- reused here rather than a generic Enum.valueOf, which is
// case-sensitive and wouldn't match every other place this type gets parsed from a raw
// string.
if (type == DeploymentTarget.class) {
return DeploymentTarget.fromConfigKey(value);
}
return value;
}
}