/** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You 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 org.apache.camel.util.component; import java.lang.reflect.Array; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.camel.RuntimeCamelException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Helper class for working with {@link ApiMethod}. */ public final class ApiMethodHelper<T extends Enum<T> & ApiMethod> { private static final Logger LOG = LoggerFactory.getLogger(ApiMethodHelper.class); // maps method name to ApiMethod private final Map<String, List<T>> methodMap; // maps method name to method arguments of the form Class type1, String name1, Class type2, String name2,... private final Map<String, List<Object>> argumentsMap; // maps argument name to argument type private final Map<String, Class<?>> validArguments; // maps aliases to actual method names private final Map<String, Set<String>> aliasesMap; // nullable args private final List<String> nullableArguments; /** * Create a helper to work with a {@link ApiMethod}, using optional method aliases. * @param apiMethodEnum {@link ApiMethod} enumeration class * @param aliases Aliases mapped to actual method names * @param nullableArguments names of arguments that default to null value */ public ApiMethodHelper(Class<T> apiMethodEnum, Map<String, String> aliases, List<String> nullableArguments) { Map<String, List<T>> tmpMethodMap = new HashMap<>(); Map<String, List<Object>> tmpArgumentsMap = new HashMap<>(); Map<String, Class<?>> tmpValidArguments = new HashMap<>(); Map<String, Set<String>> tmpAliasesMap = new HashMap<>(); // validate ApiMethod Enum if (apiMethodEnum == null) { throw new IllegalArgumentException("ApiMethod enumeration cannot be null"); } if (nullableArguments != null && !nullableArguments.isEmpty()) { this.nullableArguments = Collections.unmodifiableList(new ArrayList<String>(nullableArguments)); } else { this.nullableArguments = Collections.emptyList(); } final Map<Pattern, String> aliasPatterns = new HashMap<Pattern, String>(); for (Map.Entry<String, String> alias : aliases.entrySet()) { if (alias.getKey() == null || alias.getValue() == null) { throw new IllegalArgumentException("Alias pattern and replacement cannot be null"); } aliasPatterns.put(Pattern.compile(alias.getKey()), alias.getValue()); } LOG.debug("Processing " + apiMethodEnum.getName()); final T[] methods = apiMethodEnum.getEnumConstants(); // load lookup maps for (T method : methods) { final String name = method.getName(); // add method name aliases for (Map.Entry<Pattern, String> aliasEntry : aliasPatterns.entrySet()) { final Matcher matcher = aliasEntry.getKey().matcher(name); if (matcher.find()) { // add method name alias String alias = matcher.replaceAll(aliasEntry.getValue()); // convert first character to lowercase assert alias.length() > 1; final char firstChar = alias.charAt(0); if (!Character.isLowerCase(firstChar)) { final StringBuilder builder = new StringBuilder(); builder.append(Character.toLowerCase(firstChar)).append(alias.substring(1)); alias = builder.toString(); } Set<String> names = tmpAliasesMap.get(alias); if (names == null) { names = new HashSet<String>(); tmpAliasesMap.put(alias, names); } names.add(name); } } // map method name to Enum List<T> overloads = tmpMethodMap.get(name); if (overloads == null) { overloads = new ArrayList<T>(); tmpMethodMap.put(method.getName(), overloads); } overloads.add(method); // add arguments for this method List<Object> arguments = tmpArgumentsMap.get(name); if (arguments == null) { arguments = new ArrayList<Object>(); tmpArgumentsMap.put(name, arguments); } // process all arguments for this method final int nArgs = method.getArgNames().size(); final String[] argNames = method.getArgNames().toArray(new String[nArgs]); final Class<?>[] argTypes = method.getArgTypes().toArray(new Class[nArgs]); for (int i = 0; i < nArgs; i++) { final String argName = argNames[i]; final Class<?> argType = argTypes[i]; if (!arguments.contains(argName)) { arguments.add(argType); arguments.add(argName); } // also collect argument names for all methods, and detect clashes here final Class<?> previousType = tmpValidArguments.get(argName); if (previousType != null && previousType != argType) { throw new IllegalArgumentException(String.format( "Argument %s has ambiguous types (%s, %s) across methods!", name, previousType, argType)); } else if (previousType == null) { tmpValidArguments.put(argName, argType); } } } // validate nullableArguments if (!tmpValidArguments.keySet().containsAll(this.nullableArguments)) { List<String> unknowns = new ArrayList<String>(this.nullableArguments); unknowns.removeAll(tmpValidArguments.keySet()); throw new IllegalArgumentException("Unknown nullable arguments " + unknowns.toString()); } // validate aliases for (Map.Entry<String, Set<String>> entry : tmpAliasesMap.entrySet()) { // look for aliases that match multiple methods final Set<String> methodNames = entry.getValue(); if (methodNames.size() > 1) { // get mapped methods final List<T> aliasedMethods = new ArrayList<T>(); for (String methodName : methodNames) { List<T> mappedMethods = tmpMethodMap.get(methodName); aliasedMethods.addAll(mappedMethods); } // look for argument overlap for (T method : aliasedMethods) { final List<String> argNames = new ArrayList<String>(method.getArgNames()); argNames.removeAll(this.nullableArguments); final Set<T> ambiguousMethods = new HashSet<T>(); for (T otherMethod : aliasedMethods) { if (method != otherMethod) { final List<String> otherArgsNames = new ArrayList<String>(otherMethod.getArgNames()); otherArgsNames.removeAll(this.nullableArguments); if (argNames.equals(otherArgsNames)) { ambiguousMethods.add(method); ambiguousMethods.add(otherMethod); } } } if (!ambiguousMethods.isEmpty()) { throw new IllegalArgumentException( String.format("Ambiguous alias %s for methods %s", entry.getKey(), ambiguousMethods)); } } } } this.methodMap = Collections.unmodifiableMap(tmpMethodMap); this.argumentsMap = Collections.unmodifiableMap(tmpArgumentsMap); this.validArguments = Collections.unmodifiableMap(tmpValidArguments); this.aliasesMap = Collections.unmodifiableMap(tmpAliasesMap); LOG.debug("Found {} unique method names in {} methods", tmpMethodMap.size(), methods.length); } /** * Gets methods that match the given name and arguments.<p/> * Note that the args list is a required subset of arguments for returned methods. * * @param name case sensitive method name or alias to lookup * @return non-null unmodifiable list of methods that take all of the given arguments, empty if there is no match */ public List<ApiMethod> getCandidateMethods(String name) { return getCandidateMethods(name, Collections.emptyList()); } /** * Gets methods that match the given name and arguments.<p/> * Note that the args list is a required subset of arguments for returned methods. * * @param name case sensitive method name or alias to lookup * @param argNames unordered required argument names * @return non-null unmodifiable list of methods that take all of the given arguments, empty if there is no match */ public List<ApiMethod> getCandidateMethods(String name, Collection<String> argNames) { List<T> methods = methodMap.get(name); if (methods == null) { if (aliasesMap.containsKey(name)) { methods = new ArrayList<T>(); for (String method : aliasesMap.get(name)) { methods.addAll(methodMap.get(method)); } } } if (methods == null) { LOG.debug("No matching method for method {}", name); return Collections.emptyList(); } int nArgs = argNames != null ? argNames.size() : 0; if (nArgs == 0) { LOG.debug("Found {} methods for method {}", methods.size(), name); return Collections.unmodifiableList(methods); } else { final List<ApiMethod> filteredSet = filterMethods(methods, MatchType.SUBSET, argNames); if (LOG.isDebugEnabled()) { LOG.debug("Found {} filtered methods for {}", filteredSet.size(), name + argNames.toString().replace('[', '(').replace(']', ')')); } return filteredSet; } } /** * Filters a list of methods to those that take the given set of arguments. * * @param methods list of methods to filter * @param matchType whether the arguments are an exact match, a subset or a super set of method args * @return methods with arguments that satisfy the match type.<p/> * For SUPER_SET match, if methods with exact match are found, methods that take a subset are ignored */ public List<ApiMethod> filterMethods(List<? extends ApiMethod> methods, MatchType matchType) { return filterMethods(methods, matchType, Collections.emptyList()); } /** * Filters a list of methods to those that take the given set of arguments. * * @param methods list of methods to filter * @param matchType whether the arguments are an exact match, a subset or a super set of method args * @param argNames argument names to filter the list * @return methods with arguments that satisfy the match type.<p/> * For SUPER_SET match, if methods with exact match are found, methods that take a subset are ignored */ public List<ApiMethod> filterMethods(List<? extends ApiMethod> methods, MatchType matchType, Collection<String> argNames) { // original arguments // supplied arguments with missing nullable arguments final List<String> withNullableArgsList; if (!nullableArguments.isEmpty()) { withNullableArgsList = new ArrayList<>(argNames); withNullableArgsList.addAll(nullableArguments); } else { withNullableArgsList = null; } // list of methods that have all args in the given names List<ApiMethod> result = new ArrayList<>(); List<ApiMethod> extraArgs = null; List<ApiMethod> nullArgs = null; for (ApiMethod method : methods) { final List<String> methodArgs = method.getArgNames(); switch (matchType) { case EXACT: // method must take all args, and no more if (methodArgs.containsAll(argNames) && argNames.containsAll(methodArgs)) { result.add(method); } break; case SUBSET: // all args are required, method may take more if (methodArgs.containsAll(argNames)) { result.add(method); } break; default: case SUPER_SET: // all method args must be present if (argNames.containsAll(methodArgs)) { if (methodArgs.containsAll(argNames)) { // prefer exact match to avoid unused args result.add(method); } else if (result.isEmpty()) { // if result is empty, add method to extra args list if (extraArgs == null) { extraArgs = new ArrayList<>(); } // method takes a subset, unused args extraArgs.add(method); } } else if (result.isEmpty() && extraArgs == null) { // avoid looking for nullable args by checking for empty result and extraArgs if (withNullableArgsList != null && withNullableArgsList.containsAll(methodArgs)) { if (nullArgs == null) { nullArgs = new ArrayList<>(); } nullArgs.add(method); } } break; } } List<ApiMethod> methodList = result.isEmpty() ? extraArgs == null ? nullArgs : extraArgs : result; // preference order is exact match, matches with extra args, matches with null args return methodList != null ? Collections.unmodifiableList(methodList) : Collections.emptyList(); } /** * Gets argument types and names for all overloaded methods and aliases with the given name. * @param name method name, either an exact name or an alias, exact matches are checked first * @return list of arguments of the form Class type1, String name1, Class type2, String name2,... */ public List<Object> getArguments(final String name) throws IllegalArgumentException { List<Object> arguments = argumentsMap.get(name); if (arguments == null) { if (aliasesMap.containsKey(name)) { arguments = new ArrayList<Object>(); for (String method : aliasesMap.get(name)) { arguments.addAll(argumentsMap.get(method)); } } } if (arguments == null) { throw new IllegalArgumentException(name); } return Collections.unmodifiableList(arguments); } /** * Get missing properties. * @param methodName method name * @param argNames available arguments * @return Set of missing argument names */ public Set<String> getMissingProperties(String methodName, Set<String> argNames) { final List<Object> argsWithTypes = getArguments(methodName); final Set<String> missingArgs = new HashSet<String>(); for (int i = 1; i < argsWithTypes.size(); i += 2) { final String name = (String) argsWithTypes.get(i); if (!argNames.contains(name)) { missingArgs.add(name); } } return missingArgs; } /** * Returns alias map. * @return alias names mapped to method names. */ public Map<String, Set<String>> getAliases() { return aliasesMap; } /** * Returns argument types and names used by all methods. * @return map with argument names as keys, and types as values */ public Map<String, Class<?>> allArguments() { return validArguments; } /** * Returns argument names that can be set to null if not specified. * @return list of argument names */ public List<String> getNullableArguments() { return nullableArguments; } /** * Get the type for the given argument name. * @param argName argument name * @return argument type */ public Class<?> getType(String argName) throws IllegalArgumentException { final Class<?> type = validArguments.get(argName); if (type == null) { throw new IllegalArgumentException(argName); } return type; } // this method is always called with Enum value lists, so the cast inside is safe // the alternative of trying to convert ApiMethod and associated classes to generic classes would a bear!!! @SuppressWarnings("unchecked") public static ApiMethod getHighestPriorityMethod(List<? extends ApiMethod> filteredMethods) { Comparable<ApiMethod> highest = null; for (ApiMethod method : filteredMethods) { if (highest == null || highest.compareTo(method) <= 0) { highest = (Comparable<ApiMethod>)method; } } return (ApiMethod)highest; } /** * Invokes given method with argument values from given properties. * * @param proxy Proxy object for invoke * @param method method to invoke * @param properties Map of arguments * @return result of method invocation * @throws org.apache.camel.RuntimeCamelException on errors */ public static Object invokeMethod(Object proxy, ApiMethod method, Map<String, Object> properties) throws RuntimeCamelException { if (LOG.isDebugEnabled()) { LOG.debug("Invoking {} with arguments {}", method.getName(), properties); } final List<String> argNames = method.getArgNames(); final Object[] values = new Object[argNames.size()]; final List<Class<?>> argTypes = method.getArgTypes(); final Class<?>[] types = argTypes.toArray(new Class[argTypes.size()]); int index = 0; for (String name : argNames) { Object value = properties.get(name); // is the parameter an array type? if (value != null && types[index].isArray()) { Class<?> type = types[index]; if (value instanceof Collection) { // convert collection to array Collection<?> collection = (Collection<?>) value; Object array = Array.newInstance(type.getComponentType(), collection.size()); if (array instanceof Object[]) { collection.toArray((Object[]) array); } else { int i = 0; for (Object el : collection) { Array.set(array, i++, el); } } value = array; } else if (value.getClass().isArray() && type.getComponentType().isAssignableFrom(value.getClass().getComponentType())) { // convert derived array to super array if needed if (type.getComponentType() != value.getClass().getComponentType()) { final int size = Array.getLength(value); Object array = Array.newInstance(type.getComponentType(), size); for (int i = 0; i < size; i++) { Array.set(array, i, Array.get(value, i)); } value = array; } } else { throw new IllegalArgumentException( String.format("Cannot convert %s to %s", value.getClass(), type)); } } values[index++] = value; } try { return method.getMethod().invoke(proxy, values); } catch (Throwable e) { if (e instanceof InvocationTargetException) { // get API exception final Throwable cause = e.getCause(); e = (cause != null) ? cause : e; } throw new RuntimeCamelException( String.format("Error invoking %s with %s: %s", method.getName(), properties, e.getMessage()), e); } } public enum MatchType { EXACT, SUBSET, SUPER_SET } }