View Javadoc
1   /*
2    * Licensed under the Apache License, Version 2.0 (the "License");
3    * you may not use this file except in compliance with the License.
4    * You may obtain a copy of the License at
5    *
6    * http://www.apache.org/licenses/LICENSE-2.0
7    *
8    * Unless required by applicable law or agreed to in writing, software
9    * distributed under the License is distributed on an "AS IS" BASIS,
10   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11   * See the License for the specific language governing permissions and
12   * limitations under the License.
13   */
14  package com.addthis.codec.plugins;
15  
16  import javax.annotation.Nonnull;
17  import javax.annotation.Nullable;
18  
19  import java.util.Collections;
20  import java.util.HashMap;
21  import java.util.HashSet;
22  import java.util.Map;
23  import java.util.Set;
24  
25  import com.google.common.annotations.Beta;
26  import com.google.common.base.Objects;
27  import com.google.common.collect.BiMap;
28  import com.google.common.collect.HashBiMap;
29  import com.google.common.collect.ImmutableBiMap;
30  import com.google.common.collect.Maps;
31  
32  import com.typesafe.config.Config;
33  import com.typesafe.config.ConfigException;
34  import com.typesafe.config.ConfigFactory;
35  import com.typesafe.config.ConfigObject;
36  import com.typesafe.config.ConfigValue;
37  import com.typesafe.config.ConfigValueType;
38  
39  import org.slf4j.Logger;
40  import org.slf4j.LoggerFactory;
41  
42  import static com.google.common.base.Preconditions.checkNotNull;
43  
44  @Beta
45  public class PluginMap {
46  
47      private static final Logger log = LoggerFactory.getLogger(PluginMap.class);
48  
49      public static final PluginMap EMPTY = new PluginMap();
50  
51      @Nonnull private final Config config;
52      @Nonnull private final BiMap<String, Class<?>> map;
53      @Nonnull private final Map<String, String> aliases;
54      @Nonnull private final Set<String> inlinedAliases;
55  
56      @Nonnull private final String category;
57      @Nonnull private final String classField;
58  
59      @Nullable private final Class<?> baseClass;
60  
61      public PluginMap(@Nonnull String category, @Nonnull Config config) {
62          this.config = config;
63          this.category = checkNotNull(category);
64          classField = config.getString("_field");
65          boolean errorMissing = config.getBoolean("_strict");
66          if (config.hasPath("_class")) {
67              String baseClassName = config.getString("_class");
68              try {
69                  baseClass = Class.forName(baseClassName);
70              } catch (ClassNotFoundException e) {
71                  log.error("could not find specified base class {} for category {}",
72                            baseClassName, category);
73                  throw new RuntimeException(e);
74              }
75          } else {
76              baseClass = null;
77          }
78          Set<String> labels = config.root().keySet();
79          BiMap<String, Class<?>> mutableMap = HashBiMap.create(labels.size());
80          Map<String, String> mutableAliasMap = new HashMap<>();
81          Set<String> mutableInlinedAliasSet = new HashSet<>();
82          for (String label : labels) {
83              if (!((label.charAt(0) != '_') || "_array".equals(label) || "_default".equals(label))) {
84                  continue;
85              }
86              ConfigValue configValue = config.root().get(label);
87              String className;
88              if (configValue.valueType() == ConfigValueType.STRING) {
89                  className = (String) configValue.unwrapped();
90              } else if (configValue.valueType() == ConfigValueType.OBJECT) {
91                  ConfigObject configObject = (ConfigObject) configValue;
92                  className = configObject.toConfig().getString("_class");
93                  if (configObject.toConfig().hasPath("_inline") &&
94                          configObject.toConfig().getBoolean("_inline")) {
95                      mutableInlinedAliasSet.add(label);
96                  }
97              } else {
98                  throw new ConfigException.WrongType(configValue.origin(), label,
99                                                      "STRING OR OBJECT", configValue.valueType().toString());
100             }
101             if (labels.contains(className)) {
102                 // points to another alias
103                 mutableAliasMap.put(label, className);
104             } else {
105                 try {
106                     Class<?> foundClass = findAndValidateClass(className);
107                     mutableMap.put(label, foundClass);
108                 } catch (ClassNotFoundException maybeSwallowed) {
109                     if (errorMissing) {
110                         throw new RuntimeException(maybeSwallowed);
111                     } else {
112                         log.warn("plugin category {} with alias {} is pointing to missing class {}",
113                                  category, label, className);
114                     }
115                 }
116             }
117         }
118         map = Maps.unmodifiableBiMap(mutableMap);
119         aliases = Collections.unmodifiableMap(mutableAliasMap);
120         checkAliasesForCycles();
121         inlinedAliases = Collections.unmodifiableSet(mutableInlinedAliasSet);
122     }
123 
124     private PluginMap() {
125         config = ConfigFactory.empty();
126         map = ImmutableBiMap.of();
127         aliases = Collections.emptyMap();
128         inlinedAliases = Collections.emptySet();
129         classField = "class";
130         category = "unknown";
131         baseClass = null;
132     }
133 
134     @Nonnull public BiMap<String, Class<?>> asBiMap() {
135         return map;
136     }
137 
138     @Nonnull public Config config() {
139         return config;
140     }
141 
142     @Nonnull public ConfigObject aliasDefaults(String alias) {
143         ConfigValue configValue = config.root().get(alias);
144         ConfigObject defaults;
145         if ((configValue != null) && (configValue.valueType() == ConfigValueType.OBJECT)) {
146             defaults = (ConfigObject) configValue;
147         } else {
148             defaults = ConfigFactory.empty().root();
149         }
150         String aliasTarget = aliases.get(alias);
151         if (aliasTarget != null) {
152             defaults = defaults.withFallback(aliasDefaults(aliasTarget));
153         }
154         return defaults;
155     }
156 
157     @Nonnull public String classField() {
158         return classField;
159     }
160 
161     @Nonnull public String category() {
162         return category;
163     }
164 
165     @Nonnull public Set<String> inlinedAliases() {
166         return inlinedAliases;
167     }
168 
169     @Nullable public Class<?> arraySugar() {
170         return getClassIfConfigured("_array");
171     }
172 
173     @Nullable public Class<?> defaultSugar() {
174         return getClassIfConfigured("_default");
175     }
176 
177     /** The base class, if any, that all configured plugins must be assignable to. */
178     @Nullable public Class<?> baseClass() {
179         return baseClass;
180     }
181 
182     /** Reverse look-up on the bi-map to get the root alias for this type; if none, then returns the class name */
183     @Nonnull public String getClassName(Class<?> type) {
184         String alt = map.inverse().get(type);
185         if (alt != null) {
186             return alt;
187         } else {
188             return type.getName();
189         }
190     }
191 
192     /** Resolves aliases that point to other aliases until pointing to a real class; then return the current label. */
193     @Nullable public String getLastAlias(String alias) {
194         String aliasTarget = aliases.get(alias);
195         if (aliasTarget != null) {
196             return getLastAlias(aliasTarget);
197         } else {
198             if (asBiMap().containsKey(alias)) {
199                 return alias;
200             } else {
201                 return null;
202             }
203         }
204     }
205 
206     /**
207      * Resolves aliases until the root class for that alias chain is found or if no such alias exsits, then
208      * try find and validate the given string as if it were the class name of an anonymous alias. This means
209      * that it if a base _class is defined for this category that all package prefixes will be tried and that
210      * any found class must be assignable to the base _class type.
211      */
212     @Nonnull public Class<?> getClass(String type) throws ClassNotFoundException {
213         Class<?> alt = map.get(type);
214         if (alt != null) {
215             return alt;
216         } else {
217             String aliasTarget = aliases.get(type);
218             if (aliasTarget != null) {
219                 return getClass(aliasTarget);
220             }
221         }
222         return findAndValidateClass(type);
223     }
224 
225     /** Like {@link #getClass(String)}, but will return null rather than try to locate a new class. */
226     @Nullable public Class<?> getClassIfConfigured(String type) {
227         Class<?> alt = map.get(type);
228         if (alt != null) {
229             return alt;
230         } else {
231             String aliasTarget = aliases.get(type);
232             if (aliasTarget != null) {
233                 return getClassIfConfigured(aliasTarget);
234             }
235         }
236         return null;
237     }
238 
239     @Override public String toString() {
240         return Objects.toStringHelper(this)
241                       .add("category", category)
242                       .add("baseClass", baseClass)
243                       .add("classField", classField)
244                       .add("map", map)
245                       .add("aliases", aliases)
246                       .add("inlined-aliases", inlinedAliases)
247                       .toString();
248     }
249 
250     @Nonnull private Class<?> findAndValidateClass(String className) throws ClassNotFoundException {
251         Class<?> classValue = null;
252         // if baseClass is defined, support shared parent package omission
253         if (baseClass != null) {
254             @Nullable String packageName = baseClass.getPackage().getName();
255             while ((packageName != null) && (classValue == null)) {
256                 String packageSugaredName = packageName + '.' + className;
257                 try {
258                     classValue = Class.forName(packageSugaredName);
259                 } catch (ClassNotFoundException ignored) {
260                     int lastDotIndex = packageName.lastIndexOf('.');
261                     if (lastDotIndex >= 0) {
262                         packageName = packageName.substring(0, lastDotIndex);
263                     } else {
264                         packageName = null;
265                     }
266                 }
267             }
268         }
269         if (classValue == null) {
270             classValue = Class.forName(className);
271         }
272         // if baseClass is defined, validate all aliased classes as being valid subtypes
273         if ((baseClass != null) && !baseClass.isAssignableFrom(classValue)) {
274             throw new ClassCastException(String.format(
275                     "plugin %s specified a base class %s and '%s: %s', is not a valid subtype",
276                     category, baseClass.getName(), classField, classValue.getName()));
277         }
278         return classValue;
279     }
280 
281     private void checkAliasesForCycles() {
282         for (String key : aliases.keySet()) {
283             Set<String> visited = new HashSet<>(aliases.size());
284             checkAliasesForCyclesHelper(key, visited);
285         }
286     }
287 
288     private void checkAliasesForCyclesHelper(String key, Set<String> visited) {
289         visited.add(key);
290         String nextKey = aliases.get(key);
291         if (nextKey == null) {
292             // should mean it is present in the bimap
293             return;
294         }
295         if (visited.contains(nextKey)) {
296             throw new ConfigException.BadValue(config.root().get(key).origin(), key, "cyclical aliases detected");
297         } else {
298             checkAliasesForCyclesHelper(nextKey, visited);
299         }
300     }
301 }