/* * Copyright 2015 Martin Kouba * * 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 org.trimou.handlebars; import static org.trimou.handlebars.OptionsHashKeys.CLASS; import static org.trimou.handlebars.OptionsHashKeys.M; import static org.trimou.handlebars.OptionsHashKeys.METHOD; import static org.trimou.handlebars.OptionsHashKeys.ON; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; import org.trimou.engine.cache.ComputingCache; import org.trimou.engine.config.ConfigurationKey; import org.trimou.engine.config.SimpleConfigurationKey; import org.trimou.exception.MustacheException; import org.trimou.exception.MustacheProblem; import org.trimou.util.ImmutableSet; import org.trimou.util.Primitives; /** * Invokes public methods with parameters via reflection. * <p> * All the helper parameters are considered to be method parameters. * <p> * The method name must be always defined - either using the key * {@link OptionsHashKeys#M}/{@link OptionsHashKeys#METHOD} or as a default * method name. * <p> * The instance specified by {@value OptionsHashKeys#ON} key is optional - if * not specified and not a static method invocation, the object at the top of * the context stack is used. The key {@link OptionsHashKeys#CLASS} may be used * to invoke a static method of a specific class. By default, the TCCL or the CL * of this helper is used to load the class if needed. * <p> * E.g. the following template will invoke * {@link String#replace(CharSequence, CharSequence)} method on {@code "foo"} * string with paramteres {@code "f"} and {@code "b"}. * * <pre> * {{invoke "f" "b" on="foo" m="replace"}} * </pre> * * <p> * In the next example, {@link String#split(String)} is invoked, the resulting * array is pushed on the context stack, we iterate over the array and render * values converted to upper case letters. * <p> * * <pre> * {{#invoke ":" on="foo:bar" m="split"}}{{#each this}}{{toUpperCase}}{{/each}}{{/invoke}} * </pre> * * <p> * It's also possible to invoke a static method: * </p> * * <pre> * {{#invoke 'MILLISECONDS' class='java.util.concurrent.TimeUnit' m='valueOf'}}{{invoke 1000L m='toSeconds'}}{{/invoke}} * </pre> * * <p> * It might be also useful to access the values of a map with non-string keys: * </p> * * <pre> * {{invoke myNonStringKey on=myMap m="get"}} * </pre> * * <p> * If no instance is specified and not a static method invocation, the object at * the top of the context stack is used: * </p> * * <pre> * {{#with item.name}}{{invoke 1 m='substring'}}{{/with}} * </pre> * * * @author Martin Kouba */ public class InvokeHelper extends BasicHelper { public static InvokeHelper of(String defaultMethodName) { return new InvokeHelper(null, defaultMethodName); } /** * Limit the size of the cache. Use zero value to disable the cache. */ public static final ConfigurationKey METHOD_CACHE_MAX_SIZE_KEY = new SimpleConfigurationKey( InvokeHelper.class.getName() + ".methodCacheMaxSize", 500L); private volatile ComputingCache<MethodKey, Optional<Method>> methodCache; private final ClassLoader classLoader; private final String defaultMethodName; public InvokeHelper() { this(null, null); } /** * * @param classLoader * The CL used to load a class for a static method invocation */ public InvokeHelper(ClassLoader classLoader) { this(classLoader, null); } /** * * @param classLoader * The CL used to load a class for a static method invocation * @param defaultMethodName */ public InvokeHelper(ClassLoader classLoader, String defaultMethodName) { if (classLoader != null) { this.classLoader = classLoader; } else { ClassLoader cl = SecurityActions.getContextClassLoader(); if (cl == null) { cl = SecurityActions.getClassLoader(InvokeHelper.class); } this.classLoader = cl; } this.defaultMethodName = defaultMethodName; } @Override public void execute(Options options) { Class<?> clazz = null; Object methodName = defaultMethodName; if (methodName == null) { methodName = options.getHash().get(M); if (methodName == null) { methodName = options.getHash().get(METHOD); } } Object instance = options.getHash().get(ON); if (instance == null) { clazz = loadClassIfNeeded(options); if (clazz == null) { instance = options.peek(); } } if (clazz == null) { clazz = instance.getClass(); } Method method = methodCache.get(new MethodKey(clazz, methodName.toString(), getParamTypes(options))).orElse(null); if (method == null) { throw new MustacheException( MustacheProblem.RENDER_HELPER_INVALID_OPTIONS, "Unable to find unambiguous method with name \"%s\" and parameter types %s on class %s [%s]", methodName, getParamTypes(options), clazz.getName(), options.getTagInfo()); } try { Object value = method.invoke(instance, options.getParameters().toArray()); if (isSection(options)) { if (value != null) { options.push(value); options.fn(); options.pop(); } } else { if (value == null) { value = configuration.getMissingValueHandler() .handle(options.getTagInfo()); } if (value != null) { convertAndAppend(options, value); } } } catch (Exception e) { throw new MustacheException(MustacheProblem.RENDER_GENERIC_ERROR, e); } } @Override public void init() { super.init(); this.methodCache = configuration.getComputingCacheFactory().create( InvokeHelper.class.getName(), new MethodComputingFunction(), null, configuration.getLongPropertyValue(METHOD_CACHE_MAX_SIZE_KEY), null); } @Override public Set<ConfigurationKey> getConfigurationKeys() { return Collections.singleton(METHOD_CACHE_MAX_SIZE_KEY); } @Override protected int numberOfRequiredParameters() { return 0; } @Override public void validate(HelperDefinition definition) { super.validate(definition); if (!definition.getHash().containsKey(METHOD) && !definition.getHash().containsKey(M) && defaultMethodName == null) { throw HelperValidator.newValidationException( "A method name must be always defined", this.getClass(), definition); } } @Override protected Set<String> getSupportedHashKeys() { return ImmutableSet.of(ON, M, METHOD, CLASS); } private static boolean matches(Method method, List<Class<?>> paramTypes) { Class<?>[] methodParamTypes = method.getParameterTypes(); if (methodParamTypes.length != paramTypes.size()) { return false; } for (int i = 0; i < methodParamTypes.length; i++) { Class<?> type = Primitives.wrap(methodParamTypes[i]); if (!type.isAssignableFrom(paramTypes.get(i))) { return false; } } return true; } private List<Class<?>> getParamTypes(Options options) { int size = options.getParameters().size(); if (size == 0) { return Collections.emptyList(); } List<Class<?>> paramTypes = new ArrayList<>(size); for (Object param : options.getParameters()) { paramTypes.add(param.getClass()); } return paramTypes; } private Class<?> loadClassIfNeeded(Options options) { Class<?> clazz = null; try { Object clazzValue = options.getHash().get(CLASS); if (clazzValue != null) { if (clazzValue instanceof Class<?>) { clazz = (Class<?>) clazzValue; } else { clazz = classLoader.loadClass(clazzValue.toString()); } } } catch (ClassNotFoundException ignored) { } return clazz; } private static final class MethodKey { private final Class<?> clazz; private final String name; private final List<Class<?>> paramTypes; private MethodKey(Class<?> clazz, String name, List<Class<?>> paramTypes) { this.clazz = clazz; this.name = name; this.paramTypes = paramTypes; } Class<?> getClazz() { return clazz; } String getName() { return name; } List<Class<?>> getParamTypes() { return paramTypes; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((clazz == null) ? 0 : clazz.hashCode()); result = prime * result + ((name == null) ? 0 : name.hashCode()); result = prime * result + ((paramTypes == null) ? 0 : paramTypes.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } MethodKey other = (MethodKey) obj; if (clazz == null) { if (other.clazz != null) { return false; } } else if (!clazz.equals(other.clazz)) { return false; } if (name == null) { if (other.name != null) { return false; } } else if (!name.equals(other.name)) { return false; } if (paramTypes == null) { if (other.paramTypes != null) { return false; } } else if (!paramTypes.equals(other.paramTypes)) { return false; } return true; } } private static class MethodComputingFunction implements ComputingCache.Function<MethodKey, Optional<Method>> { @Override public Optional<Method> compute(MethodKey key) { List<Method> found = findMethods(key.getClazz(), key.getName()); if (found.isEmpty()) { return Optional.empty(); } found.removeIf(method -> !matches(method, key.getParamTypes())); if (found.size() == 1) { Method method = found.get(0); if ((!Modifier.isPublic(method.getModifiers()) || !Modifier .isPublic(method.getDeclaringClass().getModifiers())) && !method.isAccessible()) { SecurityActions.setAccessible(method); } return Optional.of(method); } return Optional.empty(); } } /** * * @param clazz * @param name * @return the list of public methods defined on the specified class and * having the specified name */ private static List<Method> findMethods(Class<?> clazz, String name) { List<Method> found = new ArrayList<>(); for (Method method : SecurityActions.getMethods(clazz)) { if (name.equals(method.getName())) { found.add(method); } } return found; } }