ConfigSubset.java
package dev.orne.config.impl;
/*-
* #%L
* Orne Config
* %%
* Copyright (C) 2019 - 2025 Orne Developments
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Lesser Public License for more details.
*
* You should have received a copy of the GNU General Lesser Public
* License along with this program. If not, see
* <http://www.gnu.org/licenses/lgpl-3.0.html>.
* #L%
*/
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Arrays;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.validation.constraints.NotNull;
import org.apiguardian.api.API;
import dev.orne.config.Config;
import dev.orne.config.ConfigException;
import dev.orne.config.MutableConfig;
import dev.orne.config.WatchableConfig;
/**
* Invocation handler for configuration subsets.
* Invokes configuration methods adding the specified prefix
* to configuration keys.
*
* @author <a href="https://github.com/ihernaez">(w) Iker Hernaez</a>
* @version 1.0, 2025-10
* @since 1.0
*/
@API(status = API.Status.INTERNAL, since = "1.0")
public class ConfigSubset
implements InvocationHandler {
/** The name of the getKeys methods. */
private static final String GET_KEYS_METHOD = "getKeys";
/** The name of the subset methods. */
private static final String SUBSET_METHOD = "subset";
/** Cached {@code Object.equals()} for performance optimization. */
private static final Method OBJECT_EQUALS;
/** Cached {@code Config.isEmpty()} for performance optimization. */
private static final Method CONFIG_IS_EMPTY;
/** Cached {@code Config.getKeys(Predicate)} for performance optimization. */
private static final Method CONFIG_GET_KEYS_FILTERED;
/** Cached {@code Config.as(Class)} for performance optimization. */
private static final Method CONFIG_AS;
/** Cached {@code Config.subset(String)} for performance optimization. */
private static final Method CONFIG_SUBSET;
/** Cached {@code MutableConfig.subset(String)} for performance optimization. */
private static final Method MUTABLE_SUBSET;
/** Cached {@code WatchableConfig.addListener(Listener)} for performance optimization. */
private static final Method WATCHABLE_ADD_LISTENER;
/** Cached {@code WatchableConfig.removeListener(Listener)} for performance optimization. */
private static final Method WATCHABLE_REMOVE_LISTENER;
/** Cached {@code WatchableConfig.subset(String)} for performance optimization. */
private static final Method WATCHABLE_SUBSET;
static {
try {
OBJECT_EQUALS = Object.class.getMethod(
"equals",
Object.class);
CONFIG_IS_EMPTY = Config.class.getMethod(
"isEmpty");
CONFIG_GET_KEYS_FILTERED = Config.class.getMethod(
GET_KEYS_METHOD,
Predicate.class);
CONFIG_AS = Config.class.getMethod(
"as",
Class.class);
CONFIG_SUBSET = MutableConfig.class.getMethod(
SUBSET_METHOD,
String.class);
MUTABLE_SUBSET = MutableConfig.class.getMethod(
SUBSET_METHOD,
String.class);
WATCHABLE_ADD_LISTENER = WatchableConfig.class.getMethod(
"addListener",
WatchableConfig.Listener.class);
WATCHABLE_REMOVE_LISTENER = WatchableConfig.class.getMethod(
"removeListener",
WatchableConfig.Listener.class);
WATCHABLE_SUBSET = WatchableConfig.class.getMethod(
SUBSET_METHOD,
String.class);
} catch (final NoSuchMethodException e) {
throw new ExceptionInInitializerError(e);
}
}
/** The configuration instance. */
private final @NotNull Config instance;
/** The prefix for configuration keys. */
private final @NotNull String prefix;
/** The configuration change events handler. */
private final EventsHandler events;
/**
* Creates a new instance.
*
* @param instance The configuration instance to be proxied.
* @param prefix The prefix for configuration keys.
*/
protected ConfigSubset(
final @NotNull Config instance,
final @NotNull String prefix) {
this.instance = Objects.requireNonNull(instance);
this.prefix = Objects.requireNonNull(prefix);
if (instance instanceof WatchableConfig) {
this.events = new EventsHandler();
} else {
this.events = null;
}
}
/**
* Creates a new configuration proxy instance with the specified configuration.
*
* @param config The proxied configuration instance.
* @param prefix The prefix for configuration keys.
* @return A new configuration proxy instance.
*/
public static @NotNull Config create(
final @NotNull Config config,
final @NotNull String prefix) {
return create(
config.getClass().getClassLoader(),
Config.class,
config,
prefix);
}
/**
* Creates a new configuration proxy instance with the specified configuration.
*
* @param config The proxied configuration instance.
* @param prefix The prefix for configuration keys.
* @return A new configuration proxy instance.
*/
public static @NotNull MutableConfig create(
final @NotNull MutableConfig config,
final @NotNull String prefix) {
return create(
config.getClass().getClassLoader(),
MutableConfig.class,
config,
prefix);
}
/**
* Creates a new configuration proxy instance with the specified configuration.
*
* @param config The proxied configuration instance.
* @param prefix The prefix for configuration keys.
* @return A new configuration proxy instance.
*/
public static @NotNull WatchableConfig create(
final @NotNull WatchableConfig config,
final @NotNull String prefix) {
return create(
config.getClass().getClassLoader(),
WatchableConfig.class,
config,
prefix);
}
/**
* Creates a new configuration proxy instance with the specified configuration.
*
* @param <T> The configuration interface type.
* @param classLoader The class loader to be used for the proxy.
* @param type The configuration interface type.
* @param config The proxied configuration instance.
* @param prefix The prefix for configuration keys.
* @return A new configuration proxy instance.
*/
protected static <T extends Config> @NotNull T create(
final @NotNull ClassLoader classLoader,
final @NotNull Class<T> type,
final @NotNull T config,
final @NotNull String prefix) {
Objects.requireNonNull(classLoader, "The class loader must not be null");
Objects.requireNonNull(config, "The configuration instance must not be null");
Objects.requireNonNull(classLoader, "The configuration subtype must be an interface.");
final ConfigSubset handler = new ConfigSubset(config, prefix);
final T proxy = type.cast(Proxy.newProxyInstance(
classLoader,
new Class<?>[] { type },
handler));
if (config instanceof WatchableConfig) {
final WatchableConfig original = (WatchableConfig) config;
final WatchableConfig proxyConfig = (WatchableConfig) proxy;
original.addListener((cfg, props) ->
handler.events.notify(proxyConfig, props.stream()
.filter(handler::isSubsetKey)
.map(handler::asSubsetKey)
.collect(Collectors.toSet())));
}
return proxy;
}
/**
* Returns the proxied configuration instance.
*
* @return The proxied configuration instance.
*/
protected @NotNull Config getInstance() {
return this.instance;
}
/**
* Returns the prefix for configuration keys.
*
* @return The prefix for configuration keys.
*/
protected @NotNull String getPrefix() {
return this.prefix;
}
/**
* Returns the configuration change events handler.
*
* @return The configuration change events handler.
*/
protected EventsHandler getEvents() {
return this.events;
}
/**
* Converts a property key of this sub-set to a delegated configuration
* key by adding the prefix.
*
* @param key The sub-set property key.
* @return The delegated configuration key.
*/
protected String asConfigKey(
final @NotNull String key) {
return this.prefix + key;
}
/**
* Checks if the specified configuration key belongs to this sub-set by
* checking the prefix.
*
* @param key The delegated configuration key.
* @return {@code true} if the key belongs to this sub-set,
* {@code false} otherwise.
*/
protected boolean isSubsetKey(
final @NotNull String key) {
return key.startsWith(this.prefix);
}
/**
* Converts a delegated configuration key to a property key of this sub-set
* by removing the prefix.
*
* @param key The delegated configuration key.
* @return The sub-set property key.
*/
protected String asSubsetKey(
final @NotNull String key) {
return key.substring(this.prefix.length());
}
/**
* {@inheritDoc}
*/
@Override
public Object invoke(
final Object proxy,
final @NotNull Method method,
final Object[] args)
throws Throwable {
try {
final Class<?> declaringClass = method.getDeclaringClass();
final Object result;
if (Object.class.equals(declaringClass)) {
result = handleObjectMethod(method, args);
} else if (Config.class.equals(declaringClass)) {
result = handleConfigMethod((Config) proxy, method, args);
} else if (MutableConfig.class.equals(declaringClass)) {
result = handleMutableMethod(method, args);
} else if (WatchableConfig.class.equals(declaringClass)) {
result = handleWatchableMethod(method, args);
} else {
result = method.invoke(this.instance, args);
}
return result;
} catch (final ReflectiveOperationException e) {
if (e.getCause() instanceof RuntimeException) {
throw (RuntimeException) e.getCause();
} else {
throw new ConfigException(e.getCause());
}
}
}
/**
* Handles {@code Object} methods invocations.
*
* @param method The invoked method.
* @param args The method arguments.
* @return The method invocation result.
* @throws ReflectiveOperationException If an error occurs during method
* invocation.
*/
protected Object handleObjectMethod(
final @NotNull Method method,
final Object[] args)
throws ReflectiveOperationException {
final Object result;
if (OBJECT_EQUALS.equals(method)) {
result = proxyEquals(args[0]);
} else {
result = method.invoke(this, args);
}
return result;
}
/**
* Checks equality with another proxy instance.
*
* @param other The other proxy instance.
* @return {@code true} if both proxies are equal,
* {@code false} otherwise.
*/
protected boolean proxyEquals(
final Object other) {
if (this == other) {
return true;
}
if (other == null || !Proxy.isProxyClass(other.getClass())) {
return false;
}
return this.equals(Proxy.getInvocationHandler(other));
}
/**
* Handles {@code Config} methods invocations.
*
* @param proxy The proxy instance.
* @param method The invoked method.
* @param args The method arguments.
* @return The method invocation result.
* @throws ReflectiveOperationException If an error occurs during method
* invocation.
*/
protected Object handleConfigMethod(
final @NotNull Config proxy,
final @NotNull Method method,
final Object[] args)
throws ReflectiveOperationException {
final Object result;
if (CONFIG_IS_EMPTY.equals(method)
&& method.getParameterCount() == 0) {
result = this.instance.getKeys(this.prefix)
.findAny()
.isEmpty();
} else if (CONFIG_AS.equals(method)) {
@SuppressWarnings("unchecked")
final Class<? extends Config> subtype =
(Class<? extends Config>) args[0];
result = ConfigSubtype.create(
proxy,
subtype);
} else if (CONFIG_SUBSET.equals(method)) {
result = create(
this.instance,
asConfigKey((String) args[0]));
} else if (GET_KEYS_METHOD.equals(method.getName())) {
result = getKeys(method, args);
} else if (args != null && args.length > 0 && args[0] instanceof String) {
final Object[] modifiedArgs = Arrays.copyOf(args, args.length);
modifiedArgs[0] = asConfigKey((String) modifiedArgs[0]);
result = method.invoke(this.instance, modifiedArgs);
} else {
result = method.invoke(this.instance, args);
}
return result;
}
/**
* Handles {@code MutableConfig} methods invocations.
*
* @param method The invoked method.
* @param args The method arguments.
* @return The method invocation result.
* @throws ReflectiveOperationException If an error occurs during method
* invocation.
*/
protected Object handleMutableMethod(
final @NotNull Method method,
final Object[] args)
throws ReflectiveOperationException {
final Object result;
if (MUTABLE_SUBSET.equals(method)) {
result = create(
(MutableConfig) this.instance,
asConfigKey((String) args[0]));
} else if (args != null && args.length > 0 && args[0] instanceof String) {
final Object[] modifiedArgs = Arrays.copyOf(args, args.length);
modifiedArgs[0] = asConfigKey((String) modifiedArgs[0]);
result = method.invoke(this.instance, modifiedArgs);
} else {
result = method.invoke(this.instance, args);
}
return result;
}
/**
* Handles {@code WatchableConfig} methods invocations.
*
* @param method The invoked method.
* @param args The method arguments.
* @return The method invocation result.
* @throws ReflectiveOperationException If an error occurs during method
* invocation.
*/
protected Object handleWatchableMethod(
final @NotNull Method method,
final Object[] args)
throws ReflectiveOperationException {
final Object result;
if (WATCHABLE_ADD_LISTENER.equals(method)) {
this.events.add((WatchableConfig.Listener) args[0]);
result = null;
} else if (WATCHABLE_REMOVE_LISTENER.equals(method)) {
this.events.remove((WatchableConfig.Listener) args[0]);
result = null;
} else if (WATCHABLE_SUBSET.equals(method)) {
result = create(
(WatchableConfig) this.instance,
asConfigKey((String) args[0]));
} else {
result = method.invoke(this.instance, args);
}
return result;
}
/**
* Handles {@code Config.getKeys()} and
* {@code Config.getKeys(String)} and
* {@code Config.getKeys(Predicate)} method invocations.
*
* @param method The invoked method.
* @param args The method arguments.
* @return The method invocation result.
* @throws ReflectiveOperationException If an error occurs during method
* invocation.
*/
protected Object getKeys(
final @NotNull Method method,
final Object[] args)
throws ReflectiveOperationException {
Object result;
if (args == null || args.length == 0) {
result = this.instance.getKeys(this.prefix)
.map(key -> key.substring(this.prefix.length()));
} else if (args.length == 1 && args[0] instanceof String) {
result = this.instance.getKeys(asConfigKey((String) args[0]))
.map(key -> key.substring(this.prefix.length()));
} else if (CONFIG_GET_KEYS_FILTERED.equals(method)) {
@SuppressWarnings("unchecked")
final Predicate<String> predicate = (Predicate<String>) args[0];
result = this.instance.getKeys(this.prefix)
.map(key -> key.substring(this.prefix.length()))
.filter(predicate);
} else {
result = method.invoke(this.instance, args);
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
public int hashCode() {
return Objects.hash(
this.instance,
this.prefix,
this.events);
}
/**
* {@inheritDoc}
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
ConfigSubset other = (ConfigSubset) obj;
return Objects.equals(this.instance, other.instance)
&& Objects.equals(this.prefix, other.prefix)
&& Objects.equals(this.events, other.events);
}
/**
* {@inheritDoc}
*/
@Override
public String toString() {
return "ConfigSubset [instance=" + this.instance + ", prefix=" + this.prefix + "]";
}
}