/*
* Copyright 2010 Proofpoint, Inc.
*
* 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.
*/
package com.proofpoint.configuration;
import com.google.common.base.Preconditions;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.collect.Table;
import com.google.inject.ConfigurationException;
import com.google.inject.spi.Message;
import com.proofpoint.configuration.Problems.Monitor;
import javax.annotation.Nullable;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.google.common.base.MoreObjects.toStringHelper;
import static com.proofpoint.configuration.TypeParameterUtils.getTypeParameters;
public class ConfigurationMetadata<T>
{
public static <T> ConfigurationMetadata<T> getValidConfigurationMetadata(Class<T> configClass) throws ConfigurationException
{
return getValidConfigurationMetadata(configClass, Problems.NULL_MONITOR);
}
static <T> ConfigurationMetadata<T> getValidConfigurationMetadata(Class<T> configClass, Problems.Monitor monitor) throws ConfigurationException
{
ConfigurationMetadata<T> metadata = getConfigurationMetadata(configClass, monitor);
metadata.getProblems().throwIfHasErrors();
return metadata;
}
public static <T> ConfigurationMetadata<T> getConfigurationMetadata(Class<T> configClass)
{
return getConfigurationMetadata(configClass, Problems.NULL_MONITOR);
}
static <T> ConfigurationMetadata<T> getConfigurationMetadata(Class<T> configClass, Problems.Monitor monitor)
{
return new ConfigurationMetadata<>(configClass, monitor);
}
private final Class<T> configClass;
private final Problems problems;
private final Constructor<T> constructor;
private final Map<String, AttributeMetadata> attributes;
private final Set<String> defunctConfig;
private ConfigurationMetadata(Class<T> configClass, Monitor monitor)
{
if (configClass == null) {
throw new NullPointerException("configClass is null");
}
this.problems = new Problems(monitor);
this.configClass = configClass;
if (Modifier.isAbstract(configClass.getModifiers())) {
problems.addError("Config class [%s] is abstract", configClass.getName());
}
this.defunctConfig = Sets.newHashSet();
if (configClass.isAnnotationPresent(DefunctConfig.class)) {
final DefunctConfig defunctConfig = configClass.getAnnotation(DefunctConfig.class);
if (defunctConfig.value().length < 1) {
problems.addError("@DefunctConfig annotation on class [%s] is empty", configClass.getName());
}
for (String defunct : configClass.getAnnotation(DefunctConfig.class).value()) {
if (defunct.isEmpty()) {
problems.addError("@DefunctConfig annotation on class [%s] contains empty values", configClass.getName());
}
else if (!this.defunctConfig.add(defunct)) {
problems.addError("Defunct property '%s' is listed more than once in @DefunctConfig for class [%s]", defunct, configClass.getName());
}
}
}
// verify there is a no-arg constructor
Constructor<T> constructor = null;
try {
constructor = configClass.getDeclaredConstructor();
constructor.setAccessible(true);
} catch (Exception e) {
problems.addError("Configuration class [%s] does not have a no-arg constructor", configClass.getName());
}
this.constructor = constructor;
this.attributes = ImmutableSortedMap.copyOf(buildAttributeMetadata(configClass));
// find invalid config methods not skipped by findConfigMethods()
for (Class<?> clazz = configClass; (clazz != null) && !clazz.equals(Object.class); clazz = clazz.getSuperclass()) {
for (Method method : clazz.getDeclaredMethods()) {
if (method.isAnnotationPresent(Config.class)) {
if (Modifier.isStatic(method.getModifiers())) {
problems.addError("@Config method [%s] is static", method.toGenericString());
}
}
}
}
if (problems.getErrors().isEmpty() && this.attributes.isEmpty() && defunctConfig.isEmpty()) {
problems.addError("Configuration class [%s] does not have any @Config annotations", configClass.getName());
}
}
public static boolean isConfigClass(Class<?> classz) {
for (Method method : classz.getDeclaredMethods()) {
if (method.isAnnotationPresent(Config.class)) {
return true;
}
}
return false;
}
public Class<T> getConfigClass()
{
return configClass;
}
public Constructor<T> getConstructor()
{
return constructor;
}
public Map<String, AttributeMetadata> getAttributes()
{
return attributes;
}
Problems getProblems()
{
return problems;
}
private boolean validateAnnotations(Method configMethod)
{
Config config = configMethod.getAnnotation(Config.class);
LegacyConfig legacyConfig = configMethod.getAnnotation(LegacyConfig.class);
if (config == null) {
problems.addError("Method [%s] must have @Config annotation", configMethod.toGenericString());
return false;
}
boolean isValid = true;
if (config.value().isEmpty()) {
problems.addError("@Config method [%s] annotation has an empty value", configMethod.toGenericString());
isValid = false;
}
if (legacyConfig != null) {
if (legacyConfig.value().length == 0) {
problems.addError("@LegacyConfig method [%s] annotation has an empty list", configMethod.toGenericString());
isValid = false;
}
if (!legacyConfig.replacedBy().isEmpty()) {
problems.addError("@Config method [%s] has annotation claiming to be replaced by another property ('%s')", configMethod.toGenericString(), legacyConfig.replacedBy());
isValid = false;
}
for (String arrayEntry : legacyConfig.value()) {
if (arrayEntry == null || arrayEntry.isEmpty()) {
problems.addError("@LegacyConfig method [%s] annotation contains null or empty value", configMethod.toGenericString());
isValid = false;
} else if (arrayEntry.equals(config.value())) {
problems.addError("@Config property name '%s' appears in @LegacyConfig annotation for method [%s]", config.value(), configMethod.toGenericString());
isValid = false;
}
}
}
return isValid;
}
private boolean validateSetter(final Method method)
{
boolean isValid = true;
if (method == null) {
return false;
}
if (!method.getName().startsWith("set")) {
problems.addError("Method [%s] is not a valid setter (e.g. setFoo) for configuration annotation", method.toGenericString());
return false;
}
if (method.getParameterTypes().length != 1) {
problems.addError("Configuration setter method [%s] does not have exactly one parameter", method.toGenericString());
return false;
}
if (method.getParameterTypes()[0] == Map.class) {
Type[] mapTypes = getTypeParameters(Map.class, method.getGenericParameterTypes()[0]);
if (mapTypes == null) {
problems.addError("Configuration setter method [%s] Map is a raw type", method.toGenericString());
return false;
}
if (!(mapTypes[0] instanceof Class)) {
problems.addError("Configuration setter method [%s] Map key type is not a concrete class", method.toGenericString());
isValid = false;
}
if (!(mapTypes[1] instanceof Class)) {
problems.addError("Configuration setter method [%s] Map value type is not a concrete class", method.toGenericString());
isValid = false;
}
else {
final Class<?> valueClass = (Class<?>) mapTypes[1];
if (isConfigClass(valueClass)) {
getConfigurationMetadata(valueClass, new Monitor()
{
@Override
public void onError(Message errorMessage)
{
problems.addError(errorMessage.getCause(),
"Configuration setter method [%s] Map value type %s: %s",
method.toGenericString(),
valueClass.getSimpleName(),
errorMessage.getMessage());
}
@Override
public void onWarning(Message warningMessage)
{
problems.addWarning("Configuration setter method [%s] Map value type %s: %s",
method.toGenericString(),
valueClass.getSimpleName(),
warningMessage.getMessage());
}
});
}
}
}
return isValid;
}
private Map<String, AttributeMetadata> buildAttributeMetadata(Class<T> configClass)
{
Map<String, AttributeMetadata> attributes = Maps.newHashMap();
for (Method configMethod : findConfigMethods(configClass)) {
configMethod.setAccessible(true);
AttributeMetadata attribute = buildAttributeMetadata(configClass, configMethod);
if (attribute != null) {
if (attributes.containsKey(attribute.getName())) {
problems.addError("Configuration class [%s] Multiple methods are annotated for @Config attribute [%s]", configClass.getName(), attribute.getName());
}
attributes.put(attribute.getName(), attribute);
}
}
// Find orphan @LegacyConfig methods, in order to report errors
Collection<Method> legacyMethods = findLegacyConfigMethods(configClass);
for (AttributeMetadata attribute : attributes.values()) {
for (InjectionPointMetaData injectionPoint : attribute.getLegacyInjectionPoints()) {
if (legacyMethods.contains(injectionPoint.getSetter())) {
// Don't care about legacy methods which are related to current attributes
legacyMethods.remove(injectionPoint.getSetter());
}
}
}
for (Method method : legacyMethods) {
if (!method.isAnnotationPresent(Config.class)) {
validateSetter(method);
problems.addError("@LegacyConfig method [%s] is not associated with any valid @Config attribute.", method.toGenericString());
}
}
// Find orphan @ConfigSecuritySensitive methods, in order to report errors
Collection<Method> sensitiveMethods = findSensitiveConfigMethods(configClass);
for (Method method : sensitiveMethods) {
if (!method.isAnnotationPresent(Config.class)) {
problems.addError("@ConfigSecuritySensitive method [%s] is not annotated with @Config.", method.toGenericString());
}
}
return attributes;
}
private AttributeMetadata buildAttributeMetadata(Class<T> configClass, Method configMethod)
{
Preconditions.checkArgument(configMethod.isAnnotationPresent(Config.class));
if (!validateAnnotations(configMethod)) {
return null;
}
String propertyName = configMethod.getAnnotation(Config.class).value();
final boolean securitySensitive = configMethod.isAnnotationPresent(ConfigSecuritySensitive.class);
// verify parameters
if (!validateSetter(configMethod)) {
return null;
}
MapClasses mapClasses = createMapClasses(configMethod);
// determine the attribute name
String attributeName = configMethod.getName().substring(3);
AttributeMetaDataBuilder builder = new AttributeMetaDataBuilder(configClass, attributeName, securitySensitive, mapClasses);
if (configMethod.isAnnotationPresent(ConfigDescription.class)) {
builder.setDescription(configMethod.getAnnotation(ConfigDescription.class).value());
}
// find the getter
Method getter = findGetter(configClass, configMethod, attributeName);
if (getter != null) {
getter.setAccessible(true);
builder.setGetter(getter);
if (configMethod.isAnnotationPresent(Deprecated.class) != getter.isAnnotationPresent(Deprecated.class)) {
problems.addError("Methods [%s] and [%s] must be @Deprecated together", configMethod, getter);
}
}
if (defunctConfig.contains(propertyName)) {
problems.addError("@Config property '%s' on method [%s] is defunct on class [%s]", propertyName, configMethod, configClass);
}
// Add the injection point for the current setter/property
builder.addInjectionPoint(InjectionPointMetaData.newCurrent(configClass, propertyName, configMethod, mapClasses));
// Add injection points for legacy setters/properties
for (InjectionPointMetaData injectionPoint : findLegacySetters(configClass, propertyName, attributeName)) {
if (!injectionPoint.getSetter().isAnnotationPresent(Config.class) && !injectionPoint.getSetter().isAnnotationPresent(Deprecated.class)) {
problems.addWarning("Replaced @LegacyConfig method [%s] should be @Deprecated", injectionPoint.getSetter().toGenericString());
}
builder.addInjectionPoint(injectionPoint);
}
return builder.build();
}
@Override
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ConfigurationMetadata<?> that = (ConfigurationMetadata<?>) o;
if (!configClass.equals(that.configClass)) return false;
return true;
}
@Override
public int hashCode()
{
return configClass.hashCode();
}
@Override
public String toString()
{
return toStringHelper(this)
.add("configClass", configClass)
.toString();
}
public static class InjectionPointMetaData
{
private final Class<?> configClass;
private final String property;
private final Method setter;
private final boolean current;
private final MapClasses mapClasses;
private static InjectionPointMetaData newCurrent(Class<?> configClass, String property, Method setter, @Nullable MapClasses mapClasses)
{
return new InjectionPointMetaData(configClass, property, setter, mapClasses, true);
}
private static InjectionPointMetaData newLegacy(Class<?> configClass, String property, Method setter)
{
return new InjectionPointMetaData(configClass, property, setter, createMapClasses(setter), false);
}
private InjectionPointMetaData(Class<?> configClass, String property, Method setter, @Nullable MapClasses mapClasses, boolean current)
{
Preconditions.checkNotNull(configClass);
Preconditions.checkNotNull(property);
Preconditions.checkNotNull(setter);
Preconditions.checkArgument(!property.isEmpty());
this.configClass = configClass;
this.property = property;
this.setter = setter;
this.mapClasses = mapClasses;
this.current = current;
}
public Class<?> getConfigClass()
{
return this.configClass;
}
public String getProperty()
{
return this.property;
}
public Method getSetter()
{
return this.setter;
}
public MapClasses getMapClasses()
{
return mapClasses;
}
public boolean isConfigMap()
{
return mapClasses != null;
}
public boolean isLegacy()
{
return !this.current;
}
@Override
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
InjectionPointMetaData that = (InjectionPointMetaData) o;
if (!configClass.equals(that.configClass)) return false;
if (!property.equals(that.property)) return false;
return true;
}
@Override
public int hashCode()
{
int result = configClass.hashCode();
result = 31 * result + property.hashCode();
return result;
}
}
@Nullable
private static MapClasses createMapClasses(Method setter)
{
MapClasses mapClasses = null;
if (setter.getParameterTypes()[0] == Map.class) {
Type[] mapTypes = getTypeParameters(Map.class, setter.getGenericParameterTypes()[0]);
mapClasses = new MapClasses((Class<?>) mapTypes[0], (Class<?>) mapTypes[1]);
}
return mapClasses;
}
public static class AttributeMetadata
{
private final Class<?> configClass;
private final String name;
private final String description;
private final boolean securitySensitive;
private final Method getter;
private final InjectionPointMetaData injectionPoint;
private final Set<InjectionPointMetaData> legacyInjectionPoints;
private final MapClasses mapClasses;
private AttributeMetadata(Class<?> configClass, String name, String description, boolean securitySensitive, @Nullable MapClasses mapClasses, Method getter,
InjectionPointMetaData injectionPoint, Set<InjectionPointMetaData> legacyInjectionPoints)
{
Preconditions.checkNotNull(configClass);
Preconditions.checkNotNull(name);
Preconditions.checkNotNull(getter);
Preconditions.checkNotNull(injectionPoint);
Preconditions.checkNotNull(legacyInjectionPoints);
this.configClass = configClass;
this.name = name;
this.description = description;
this.securitySensitive = securitySensitive;
this.mapClasses = mapClasses;
this.getter = getter;
this.injectionPoint = injectionPoint;
this.legacyInjectionPoints = ImmutableSet.copyOf(legacyInjectionPoints);
}
public Class<?> getConfigClass()
{
return configClass;
}
public String getName()
{
return name;
}
public String getDescription()
{
return description;
}
public boolean isSecuritySensitive()
{
return securitySensitive;
}
public MapClasses getMapClasses()
{
return mapClasses;
}
public Method getGetter()
{
return getter;
}
public InjectionPointMetaData getInjectionPoint()
{
return this.injectionPoint;
}
public Set<InjectionPointMetaData> getLegacyInjectionPoints()
{
return this.legacyInjectionPoints;
}
@Override
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AttributeMetadata that = (AttributeMetadata) o;
if (!configClass.equals(that.configClass)) return false;
if (!name.equals(that.name)) return false;
return true;
}
@Override
public int hashCode()
{
int result = configClass.hashCode();
result = 31 * result + name.hashCode();
return result;
}
@Override
public String toString()
{
return toStringHelper(this)
.add("name", name)
.toString();
}
}
private static class AttributeMetaDataBuilder
{
private final Class<?> configClass;
private final String name;
private String description = null;
private Method getter = null;
private InjectionPointMetaData injectionPoint = null;
private final Set<InjectionPointMetaData> legacyInjectionPoints = Sets.newHashSet();
private final boolean securitySensitive;
private final MapClasses mapClasses;
AttributeMetaDataBuilder(Class<?> configClass, String name, boolean securitySensitive, @Nullable MapClasses mapClasses)
{
Preconditions.checkNotNull(configClass);
Preconditions.checkNotNull(name);
Preconditions.checkArgument(!name.isEmpty());
this.configClass = configClass;
this.name = name;
this.securitySensitive = securitySensitive;
this.mapClasses = mapClasses;
}
public void setDescription(String description)
{
Preconditions.checkNotNull(description);
this.description = description;
}
public void setGetter(Method getter)
{
Preconditions.checkNotNull(getter);
this.getter = getter;
}
public void addInjectionPoint(InjectionPointMetaData injectionPointMetaData)
{
Preconditions.checkNotNull(injectionPointMetaData);
if (injectionPointMetaData.isLegacy()) {
this.legacyInjectionPoints.add(injectionPointMetaData);
return;
}
if (this.injectionPoint != null) {
throw Problems.exceptionFor("Trying to set current property twice: '%s' on method [%s] and '%s' on method [%s]",
this.injectionPoint.getProperty(), this.injectionPoint.getSetter().toGenericString(),
injectionPointMetaData.getProperty(), injectionPointMetaData.getSetter().toGenericString());
}
this.injectionPoint = injectionPointMetaData;
}
public AttributeMetadata build()
{
// todo fix validation
if (getter == null) {
return null;
}
return new AttributeMetadata(configClass, name, description, securitySensitive, mapClasses, getter, injectionPoint, legacyInjectionPoints);
}
}
private static Collection<Method> findConfigMethods(Class<?> configClass)
{
return findAnnotatedMethods(configClass, Config.class);
}
private static Collection<Method> findLegacyConfigMethods(Class<?> configClass)
{
return findAnnotatedMethods(configClass, LegacyConfig.class);
}
private static Collection<Method> findSensitiveConfigMethods(Class<?> configClass)
{
return findAnnotatedMethods(configClass, ConfigSecuritySensitive.class);
}
/**
* Find methods that are tagged with a given annotation somewhere in the hierarchy
*
* @param configClass the class to analyze
* @return a map that associates a concrete method to the actual method tagged
* (which may belong to a different class in class hierarchy)
*/
private static Collection<Method> findAnnotatedMethods(Class<?> configClass, Class<? extends java.lang.annotation.Annotation> annotation)
{
Table<String, Class<?>[], Method> methodTable = HashBasedTable.create();
// gather all available methods
for (Class<?> aClass = configClass; aClass != Object.class; aClass = aClass.getSuperclass()) {
for (Method method : aClass.getDeclaredMethods()) {
if (methodTable.contains(method.getName(), method.getParameterTypes())) {
continue;
}
// skip methods that are used internally by the vm for implementing covariance, etc
if (method.isSynthetic() || method.isBridge() || Modifier.isStatic(method.getModifiers())) {
continue;
}
// look for annotations recursively in super-classes or interfaces
Method managedMethod = findAnnotatedMethod(configClass, annotation, method.getName(), method.getParameterTypes());
if (managedMethod != null) {
methodTable.put(method.getName(), method.getParameterTypes(), managedMethod);
}
}
}
return methodTable.values();
}
public static Method findAnnotatedMethod(Class<?> configClass, Class<? extends java.lang.annotation.Annotation> annotation, String methodName, Class<?>... paramTypes)
{
try {
Method method = configClass.getDeclaredMethod(methodName, paramTypes);
if (method != null && method.isAnnotationPresent(annotation)) {
return method;
}
} catch (NoSuchMethodException e) {
// ignore
}
if (configClass.getSuperclass() != null) {
Method managedMethod = findAnnotatedMethod(configClass.getSuperclass(), annotation, methodName, paramTypes);
if (managedMethod != null) {
return managedMethod;
}
}
for (Class<?> iface : configClass.getInterfaces()) {
Method managedMethod = findAnnotatedMethod(iface, annotation, methodName, paramTypes);
if (managedMethod != null) {
return managedMethod;
}
}
return null;
}
private Set<InjectionPointMetaData> findLegacySetters(Class<?> configClass, String propertyName, String attributeName)
{
Set<InjectionPointMetaData> setters = Sets.newHashSet();
String setterName = "set" + attributeName;
for (Class<?> clazz = configClass; (clazz != null) && !clazz.equals(Object.class); clazz = clazz.getSuperclass()) {
for (Method method : clazz.getDeclaredMethods()) {
method.setAccessible(true);
if (isUsableMethod(method)) {
if (method.getName().equals(setterName) && method.isAnnotationPresent(LegacyConfig.class)) {
// Found @LegacyConfig setter with matching attribute name
if (validateSetter(method)) {
for (String property : method.getAnnotation(LegacyConfig.class).value()) {
if (defunctConfig.contains(property)) {
problems.addError("@LegacyConfig property '%s' on method [%s] is defunct on class [%s]", property, method, configClass);
}
if (!property.equals(propertyName)) {
setters.add(InjectionPointMetaData.newLegacy(configClass, property, method));
} else {
problems.addError("@LegacyConfig property '%s' on method [%s] is replaced by @Config property of same name on method [%s]",
property, method.toGenericString(), setterName);
}
}
}
} else if (method.isAnnotationPresent(LegacyConfig.class)
&& method.getAnnotation(LegacyConfig.class).replacedBy().equals(propertyName)) {
// Found @LegacyConfig setter linked by replacedBy() property
if (validateSetter(method)) {
for (String property : method.getAnnotation(LegacyConfig.class).value()) {
if (defunctConfig.contains(property)) {
problems.addError("@LegacyConfig property '%s' on method [%s] is defunct on class [%s]", property, method, configClass);
}
if (!property.equals(propertyName)) {
setters.add(InjectionPointMetaData.newLegacy(configClass, property, method));
} else {
problems.addError("@LegacyConfig property '%s' on method [%s] is replaced by @Config property of same name on method [%s]",
property, method.toGenericString(), setterName);
}
}
}
}
}
}
}
return setters;
}
private Method findGetter(Class<?> configClass, Method configMethod, String attributeName)
{
// find the getter or is function
String getterName = "get" + attributeName;
String isName = "is" + attributeName;
List<Method> getters = new ArrayList<Method>();
List<Method> unusableGetters = new ArrayList<Method>();
for (Class<?> clazz = configClass; (clazz != null) && !clazz.equals(Object.class); clazz = clazz.getSuperclass()) {
for (Method method : clazz.getDeclaredMethods()) {
if (method.getName().equals(getterName) || method.getName().equals(isName)) {
if (isUsableMethod(method) && !method.getReturnType().equals(Void.TYPE) && method.getParameterTypes().length == 0) {
getters.add(method);
} else {
unusableGetters.add(method);
}
}
}
}
// too small
if (getters.isEmpty()) {
String unusable = "";
if (!unusableGetters.isEmpty()) {
StringBuilder builder = new StringBuilder(" The following methods are unusable: ");
for (Method method : unusableGetters) {
builder.append('[').append(method.toGenericString()).append(']');
}
unusable = builder.toString();
}
problems.addError("No getter for @Config method [%s].%s", configMethod.toGenericString(), unusable);
return null;
}
// too big
if (getters.size() > 1) {
// To many getters
problems.addError("Multiple getters found for @Config setter [%s]", configMethod.toGenericString());
return null;
}
// just right
return getters.get(0);
}
private static boolean isUsableMethod(Method method)
{
return !method.isSynthetic() && !method.isBridge() && !Modifier.isStatic(method.getModifiers());
}
}