/*
* 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.addthis.codec.plugins;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import com.google.common.annotations.Beta;
import com.google.common.base.Objects;
import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.Maps;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigException;
import com.typesafe.config.ConfigFactory;
import com.typesafe.config.ConfigObject;
import com.typesafe.config.ConfigValue;
import com.typesafe.config.ConfigValueType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.google.common.base.Preconditions.checkNotNull;
@Beta
public class PluginMap {
private static final Logger log = LoggerFactory.getLogger(PluginMap.class);
public static final PluginMap EMPTY = new PluginMap();
@Nonnull private final Config config;
@Nonnull private final BiMap<String, Class<?>> map;
@Nonnull private final Map<String, String> aliases;
@Nonnull private final Set<String> inlinedAliases;
@Nonnull private final String category;
@Nonnull private final String classField;
@Nullable private final Class<?> baseClass;
public PluginMap(@Nonnull String category, @Nonnull Config config) {
this.config = config;
this.category = checkNotNull(category);
classField = config.getString("_field");
boolean errorMissing = config.getBoolean("_strict");
if (config.hasPath("_class")) {
String baseClassName = config.getString("_class");
try {
baseClass = Class.forName(baseClassName);
} catch (ClassNotFoundException e) {
log.error("could not find specified base class {} for category {}",
baseClassName, category);
throw new RuntimeException(e);
}
} else {
baseClass = null;
}
Set<String> labels = config.root().keySet();
BiMap<String, Class<?>> mutableMap = HashBiMap.create(labels.size());
Map<String, String> mutableAliasMap = new HashMap<>();
Set<String> mutableInlinedAliasSet = new HashSet<>();
for (String label : labels) {
if (!((label.charAt(0) != '_') || "_array".equals(label) || "_default".equals(label))) {
continue;
}
ConfigValue configValue = config.root().get(label);
String className;
if (configValue.valueType() == ConfigValueType.STRING) {
className = (String) configValue.unwrapped();
} else if (configValue.valueType() == ConfigValueType.OBJECT) {
ConfigObject configObject = (ConfigObject) configValue;
className = configObject.toConfig().getString("_class");
if (configObject.toConfig().hasPath("_inline") &&
configObject.toConfig().getBoolean("_inline")) {
mutableInlinedAliasSet.add(label);
}
} else {
throw new ConfigException.WrongType(configValue.origin(), label,
"STRING OR OBJECT", configValue.valueType().toString());
}
if (labels.contains(className)) {
// points to another alias
mutableAliasMap.put(label, className);
} else {
try {
Class<?> foundClass = findAndValidateClass(className);
mutableMap.put(label, foundClass);
} catch (ClassNotFoundException maybeSwallowed) {
if (errorMissing) {
throw new RuntimeException(maybeSwallowed);
} else {
log.warn("plugin category {} with alias {} is pointing to missing class {}",
category, label, className);
}
}
}
}
map = Maps.unmodifiableBiMap(mutableMap);
aliases = Collections.unmodifiableMap(mutableAliasMap);
checkAliasesForCycles();
inlinedAliases = Collections.unmodifiableSet(mutableInlinedAliasSet);
}
private PluginMap() {
config = ConfigFactory.empty();
map = ImmutableBiMap.of();
aliases = Collections.emptyMap();
inlinedAliases = Collections.emptySet();
classField = "class";
category = "unknown";
baseClass = null;
}
@Nonnull public BiMap<String, Class<?>> asBiMap() {
return map;
}
@Nonnull public Config config() {
return config;
}
@Nonnull public ConfigObject aliasDefaults(String alias) {
ConfigValue configValue = config.root().get(alias);
ConfigObject defaults;
if ((configValue != null) && (configValue.valueType() == ConfigValueType.OBJECT)) {
defaults = (ConfigObject) configValue;
} else {
defaults = ConfigFactory.empty().root();
}
String aliasTarget = aliases.get(alias);
if (aliasTarget != null) {
defaults = defaults.withFallback(aliasDefaults(aliasTarget));
}
return defaults;
}
@Nonnull public String classField() {
return classField;
}
@Nonnull public String category() {
return category;
}
@Nonnull public Set<String> inlinedAliases() {
return inlinedAliases;
}
@Nullable public Class<?> arraySugar() {
return getClassIfConfigured("_array");
}
@Nullable public Class<?> defaultSugar() {
return getClassIfConfigured("_default");
}
/** The base class, if any, that all configured plugins must be assignable to. */
@Nullable public Class<?> baseClass() {
return baseClass;
}
/** Reverse look-up on the bi-map to get the root alias for this type; if none, then returns the class name */
@Nonnull public String getClassName(Class<?> type) {
String alt = map.inverse().get(type);
if (alt != null) {
return alt;
} else {
return type.getName();
}
}
/** Resolves aliases that point to other aliases until pointing to a real class; then return the current label. */
@Nullable public String getLastAlias(String alias) {
String aliasTarget = aliases.get(alias);
if (aliasTarget != null) {
return getLastAlias(aliasTarget);
} else {
if (asBiMap().containsKey(alias)) {
return alias;
} else {
return null;
}
}
}
/**
* Resolves aliases until the root class for that alias chain is found or if no such alias exsits, then
* try find and validate the given string as if it were the class name of an anonymous alias. This means
* that it if a base _class is defined for this category that all package prefixes will be tried and that
* any found class must be assignable to the base _class type.
*/
@Nonnull public Class<?> getClass(String type) throws ClassNotFoundException {
Class<?> alt = map.get(type);
if (alt != null) {
return alt;
} else {
String aliasTarget = aliases.get(type);
if (aliasTarget != null) {
return getClass(aliasTarget);
}
}
return findAndValidateClass(type);
}
/** Like {@link #getClass(String)}, but will return null rather than try to locate a new class. */
@Nullable public Class<?> getClassIfConfigured(String type) {
Class<?> alt = map.get(type);
if (alt != null) {
return alt;
} else {
String aliasTarget = aliases.get(type);
if (aliasTarget != null) {
return getClassIfConfigured(aliasTarget);
}
}
return null;
}
@Override public String toString() {
return Objects.toStringHelper(this)
.add("category", category)
.add("baseClass", baseClass)
.add("classField", classField)
.add("map", map)
.add("aliases", aliases)
.add("inlined-aliases", inlinedAliases)
.toString();
}
@Nonnull private Class<?> findAndValidateClass(String className) throws ClassNotFoundException {
Class<?> classValue = null;
// if baseClass is defined, support shared parent package omission
if (baseClass != null) {
@Nullable String packageName = baseClass.getPackage().getName();
while ((packageName != null) && (classValue == null)) {
String packageSugaredName = packageName + '.' + className;
try {
classValue = Class.forName(packageSugaredName);
} catch (ClassNotFoundException ignored) {
int lastDotIndex = packageName.lastIndexOf('.');
if (lastDotIndex >= 0) {
packageName = packageName.substring(0, lastDotIndex);
} else {
packageName = null;
}
}
}
}
if (classValue == null) {
classValue = Class.forName(className);
}
// if baseClass is defined, validate all aliased classes as being valid subtypes
if ((baseClass != null) && !baseClass.isAssignableFrom(classValue)) {
throw new ClassCastException(String.format(
"plugin %s specified a base class %s and '%s: %s', is not a valid subtype",
category, baseClass.getName(), classField, classValue.getName()));
}
return classValue;
}
private void checkAliasesForCycles() {
for (String key : aliases.keySet()) {
Set<String> visited = new HashSet<>(aliases.size());
checkAliasesForCyclesHelper(key, visited);
}
}
private void checkAliasesForCyclesHelper(String key, Set<String> visited) {
visited.add(key);
String nextKey = aliases.get(key);
if (nextKey == null) {
// should mean it is present in the bimap
return;
}
if (visited.contains(nextKey)) {
throw new ConfigException.BadValue(config.root().get(key).origin(), key, "cyclical aliases detected");
} else {
checkAliasesForCyclesHelper(nextKey, visited);
}
}
}