/* Copyright 2005-2006 Tim Fennell * * 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 net.sourceforge.stripes.controller; import net.sourceforge.stripes.action.ActionBean; import net.sourceforge.stripes.action.ActionBeanContext; import net.sourceforge.stripes.action.DefaultHandler; import net.sourceforge.stripes.action.HandlesEvent; import net.sourceforge.stripes.action.SessionScope; import net.sourceforge.stripes.config.BootstrapPropertyResolver; import net.sourceforge.stripes.config.Configuration; import net.sourceforge.stripes.config.DontAutoLoad; import net.sourceforge.stripes.exception.ActionBeanNotFoundException; import net.sourceforge.stripes.exception.StripesRuntimeException; import net.sourceforge.stripes.exception.StripesServletException; import net.sourceforge.stripes.util.HttpUtil; import net.sourceforge.stripes.util.Log; import net.sourceforge.stripes.util.ResolverUtil; import net.sourceforge.stripes.util.StringUtil; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; 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.concurrent.ConcurrentHashMap; /** * <p> * Uses Annotations on classes to identify the ActionBean that corresponds to * the current request. ActionBeans are annotated with an {@code @UrlBinding} * annotation, which denotes the web application relative URL that the * ActionBean should respond to.</p> * * <p> * Individual methods on ActionBean classes are expected to be annotated with * @HandlesEvent annotations, and potentially a @DefaultHandler annotation. * Using these annotations the Resolver will determine which method should be * executed for the current request.</p> * * @see net.sourceforge.stripes.action.UrlBinding * @author Tim Fennell */ public class AnnotatedClassActionResolver implements ActionResolver { /** * Configuration key used to lookup a comma-separated list of package names. * The packages (and their sub-packages) will be scanned for implementations * of ActionBean. * * @since Stripes 1.5 */ public static final String PACKAGES = "ActionResolver.Packages"; /** * Key used to store the default handler in the Map of handler methods. */ private static final String DEFAULT_HANDLER_KEY = "__default_handler"; /** * Log instance for use within in this class. */ private static final Log log = Log.getInstance(AnnotatedClassActionResolver.class); /** * Handle to the configuration. */ private Configuration configuration; /** * Parses {@link UrlBinding} values and maps request URLs to * {@link ActionBean}s. */ private UrlBindingFactory urlBindingFactory = new UrlBindingFactory(); /** * Maps action bean classes simple name -> action bean class */ protected final Map<String, Class<? extends ActionBean>> actionBeansByName = new ConcurrentHashMap<String, Class<? extends ActionBean>>(); /** * Map used to resolve the methods handling events within form beans. Maps * the class representing a subclass of ActionBean to a Map of event names * to Method objects. */ private Map<Class<? extends ActionBean>, Map<String, Method>> eventMappings = new HashMap<Class<? extends ActionBean>, Map<String, Method>>() { private static final long serialVersionUID = 1L; @Override public Map<String, Method> get(Object key) { Map<String, Method> value = super.get(key); if (value == null) { return Collections.emptyMap(); } else { return value; } } }; /** * Scans the classpath of the current classloader (not including parents) to * find implementations of the ActionBean interface. Examines annotations on * the classes found to determine what forms and events they map to, and * stores this information in a pair of maps for fast access during request * processing. */ public void init(Configuration configuration) throws Exception { this.configuration = configuration; // Process each ActionBean for (Class<? extends ActionBean> clazz : findClasses()) { addActionBean(clazz); } addBeanNameMappings(); } protected void addBeanNameMappings() { Set<String> foundBeanNames = new HashSet<String>(); for (Class<? extends ActionBean> clazz : getActionBeanClasses()) { if (foundBeanNames.contains(clazz.getSimpleName())) { log.warn("Found multiple action beans with the same simple name: ", clazz.getSimpleName(), ". You will " + "need to reference these action beans by their fully qualified names"); actionBeansByName.remove(clazz.getSimpleName()); continue; } foundBeanNames.add(clazz.getSimpleName()); actionBeansByName.put(clazz.getSimpleName(), clazz); } } /** * Get the {@link UrlBindingFactory} that is being used by this action * resolver. */ public UrlBindingFactory getUrlBindingFactory() { return urlBindingFactory; } /** * Adds an ActionBean class to the set that this resolver can resolve. * Identifies the URL binding and the events managed by the class and stores * them in Maps for fast lookup. * * @param clazz a class that implements ActionBean */ protected void addActionBean(Class<? extends ActionBean> clazz) { // Ignore abstract classes if (Modifier.isAbstract(clazz.getModifiers()) || clazz.isAnnotationPresent(DontAutoLoad.class)) { return; } String binding = getUrlBinding(clazz); if (binding == null) { return; } // make sure mapping exists in cache UrlBinding proto = getUrlBindingFactory().getBindingPrototype(clazz); if (proto == null) { getUrlBindingFactory().addBinding(clazz, new UrlBinding(clazz, binding)); } // Construct the mapping of event->method for the class Map<String, Method> classMappings = new HashMap<String, Method>(); processMethods(clazz, classMappings); // Put the event->method mapping for the class into the set of mappings this.eventMappings.put(clazz, classMappings); if (proto != null) { proto.initDefaultValueWithDefaultHandlerIfNeeded(this); } if (log.getRealLog().isDebugEnabled()) { // Print out the event mappings nicely for (Map.Entry<String, Method> entry : classMappings.entrySet()) { String event = entry.getKey(); Method handler = entry.getValue(); boolean isDefault = DEFAULT_HANDLER_KEY.equals(event); log.debug("Bound: ", clazz.getSimpleName(), ".", handler.getName(), "() ==> ", binding, isDefault ? "" : "?" + event); } } } /** * Removes an ActionBean class from the set that this resolver can resolve. * The URL binding and the events managed by the class are removed from the * cache. * * @param clazz a class that implements ActionBean */ protected void removeActionBean(Class<? extends ActionBean> clazz) { String binding = getUrlBinding(clazz); if (binding != null) { getUrlBindingFactory().removeBinding(clazz); } eventMappings.remove(clazz); } /** * Returns the URL binding that is a substring of the path provided. For * example, if there is an ActionBean bound to {@code /user/Profile.action} * the path {@code /user/Profile.action/view} would return * {@code /user/Profile.action}. * * @param path the path being used to access an ActionBean, either in a form * or link tag, or in a request that is hitting the DispatcherServlet. * @return the UrlBinding of the ActionBean appropriate for the request, or * null if the path supplied cannot be mapped to an ActionBean. */ public String getUrlBindingFromPath(String path) { UrlBinding mapping = getUrlBindingFactory().getBindingPrototype(path); return mapping == null ? null : mapping.toString(); } /** * Takes a class that implements ActionBean and returns the URL binding of * that class. The default implementation retrieves the UrlBinding * annotations and returns its value. Subclasses could do more complex * things like parse the class and package names and construct a "default" * binding when one is not specified. * * @param clazz a class that implements ActionBean * @return the UrlBinding or null if none can be determined */ public String getUrlBinding(Class<? extends ActionBean> clazz) { UrlBinding mapping = getUrlBindingFactory().getBindingPrototype(clazz); return mapping == null ? null : mapping.toString(); } /** * Helper method that examines a class, starting at its highest super class * and working its way down again, to find method annotations and ensure * that child class annotations take precedence. */ protected void processMethods(Class<?> clazz, Map<String, Method> classMappings) { // Do the super class first if there is one Class<?> superclass = clazz.getSuperclass(); if (superclass != null) { processMethods(superclass, classMappings); } Method[] methods = clazz.getDeclaredMethods(); for (Method method : methods) { if (Modifier.isPublic(method.getModifiers()) && !method.isBridge()) { String eventName = getHandledEvent(method); // look for duplicate event names within the current class if (classMappings.containsKey(eventName) && clazz.equals(classMappings.get(eventName).getDeclaringClass())) { throw new StripesRuntimeException("The ActionBean " + clazz + " declares multiple event handlers for event '" + eventName + "'"); } DefaultHandler defaultMapping = method.getAnnotation(DefaultHandler.class); if (eventName != null) { classMappings.put(eventName, method); } if (defaultMapping != null) { // look for multiple default handlers within the current class if (classMappings.containsKey(DEFAULT_HANDLER_KEY) && clazz.equals(classMappings.get(DEFAULT_HANDLER_KEY).getDeclaringClass())) { throw new StripesRuntimeException("The ActionBean " + clazz + " declares multiple default event handlers"); } // Makes sure we catch the default handler classMappings.put(DEFAULT_HANDLER_KEY, method); } } } } /** * Responsible for determining the name of the event handled by this method, * if indeed it handles one at all. By default looks for the HandlesEvent * annotations and returns its value if present. * * @param handler a method that might or might not be a handler method * @return the name of the event handled, or null */ public String getHandledEvent(Method handler) { HandlesEvent mapping = handler.getAnnotation(HandlesEvent.class); if (mapping != null) { return mapping.value(); } else { return null; } } /** * <p> * Fetches the Class representing the type of ActionBean that would respond * were a request made with the path specified. Checks to see if the full * path matches any bean's UrlBinding. If no ActionBean matches then * successively removes path segments (separated by slashes) from the end of * the path until a match is found.</p> * * @param path the path segment of a URL * @return the Class object for the type of action bean that will respond if * a request is made using the path specified or null if no ActionBean * matches. */ public Class<? extends ActionBean> getActionBeanType(String path) { UrlBinding binding = getUrlBindingFactory().getBindingPrototype(path); return binding == null ? null : binding.getBeanType(); } /** * Gets the logical name of the ActionBean that should handle the request. * Implemented to look up the name of the form based on the name assigned to * the form in the form tag, and encoded in a hidden field. * * @param context the ActionBeanContext for the current request * @return the name of the form to be used for this request */ public ActionBean getActionBean(ActionBeanContext context) throws StripesServletException { HttpServletRequest request = context.getRequest(); String path = HttpUtil.getRequestedPath(request); ActionBean bean = getActionBean(context, path); request.setAttribute(RESOLVED_ACTION, getUrlBindingFromPath(path)); return bean; } /** * Returns the ActionBean class that is bound to the UrlBinding supplied. If * the action bean already exists in the appropriate scope (request or * session) then the existing instance will be supplied. If not, then a new * instance will be manufactured and have the supplied ActionBeanContext set * on it. * * @param path a URL to which an ActionBean is bound, or a path starting * with the URL to which an ActionBean has been bound. * @param context the current ActionBeanContext * @return a Class<ActionBean> for the ActionBean requested * @throws StripesServletException if the UrlBinding does not match an * ActionBean binding */ public ActionBean getActionBean(ActionBeanContext context, String path) throws StripesServletException { Class<? extends ActionBean> beanClass = getActionBeanType(path); ActionBean bean; if (beanClass == null) { throw new ActionBeanNotFoundException(path, getUrlBindingFactory().getPathMap()); } String bindingPath = getUrlBinding(beanClass); try { HttpServletRequest request = context.getRequest(); if (beanClass.isAnnotationPresent(SessionScope.class)) { bean = (ActionBean) request.getSession().getAttribute(bindingPath); if (bean == null) { bean = makeNewActionBean(beanClass, context); request.getSession().setAttribute(bindingPath, bean); } } else { bean = (ActionBean) request.getAttribute(bindingPath); if (bean == null) { bean = makeNewActionBean(beanClass, context); request.setAttribute(bindingPath, bean); } } setActionBeanContext(bean, context); } catch (Exception e) { StripesServletException sse = new StripesServletException( "Could not create instance of ActionBean type [" + beanClass.getName() + "].", e); throw sse; } assertGetContextWorks(bean); return bean; } /** * Calls {@link ActionBean#setContext(ActionBeanContext)} with the given * {@code context} only if necessary. Subclasses should use this method * instead of setting the context directly because it can be somewhat tricky * to determine when it needs to be done. * * @param bean The bean whose context may need to be set. * @param context The context to pass to the bean if necessary. */ protected void setActionBeanContext(ActionBean bean, ActionBeanContext context) { ActionBeanContext abcFromBean = bean.getContext(); if (abcFromBean == null) { bean.setContext(context); } else { StripesRequestWrapper wrapperFromBean = StripesRequestWrapper .findStripesWrapper(abcFromBean.getRequest()); StripesRequestWrapper wrapperFromRequest = StripesRequestWrapper .findStripesWrapper(context.getRequest()); if (wrapperFromBean != wrapperFromRequest) { bean.setContext(context); } } } /** * Since many down stream parts of Stripes rely on the ActionBean properly * returning the context it is given, we'll just test it up front. Called * after the bean is instantiated. * * @param bean the ActionBean to test to see if getContext() works correctly * @throws StripesServletException if getContext() returns null */ protected void assertGetContextWorks(final ActionBean bean) throws StripesServletException { if (bean.getContext() == null) { throw new StripesServletException("Ahem. Stripes has just resolved and instantiated " + "the ActionBean class " + bean.getClass().getName() + " and set the ActionBeanContext " + "on it. However calling getContext() isn't returning the context back! Since " + "this is required for several parts of Stripes to function correctly you should " + "now stop and implement setContext()/getContext() correctly. Thank you."); } } /** * Helper method to construct and return a new ActionBean instance. Called * whenever a new instance needs to be manufactured. Provides a convenient * point for subclasses to add specific behaviour during action bean * creation. * * @param type the type of ActionBean to create * @param context the current ActionBeanContext * @return the new ActionBean instance * @throws Exception if anything goes wrong! */ protected ActionBean makeNewActionBean(Class<? extends ActionBean> type, ActionBeanContext context) throws Exception { return getConfiguration().getObjectFactory().newInstance(type); } /** * <p> * Try various means to determine which event is to be executed on the * current ActionBean. If a 'special' request attribute * ({@link StripesConstants#REQ_ATTR_EVENT_NAME}) is present in the request, * then return its value. This attribute is used to handle internal * forwards, when request parameters are merged and cannot reliably * determine the desired event name. * </p> * * <p> * If that doesn't work, the value of a 'special' request parameter * ({@link StripesConstants#URL_KEY_EVENT_NAME}) is checked to see if * contains a single value matching an event name. * </p> * * <p> * Failing that, search for a parameter in the request whose name matches * one of the named events handled by the ActionBean. For example, if the * ActionBean can handle events foo and bar, this method will scan the * request for foo=somevalue and bar=somevalue. If it finds a request * parameter with a matching name it will return that name. If there are * multiple matching names, the result of this method cannot be guaranteed * and a {@link StripesRuntimeException} will be thrown. * </p> * * <p> * Finally, if the event name cannot be determined through the parameter * names and there is extra path information beyond the URL binding of the * ActionBean, it is checked to see if it matches an event name. * </p> * * @param bean the ActionBean type bound to the request * @param context the ActionBeanContect for the current request * @return String the name of the event submitted, or null if none can be * found */ public String getEventName(Class<? extends ActionBean> bean, ActionBeanContext context) { String event = getEventNameFromRequestAttribute(bean, context); if (event == null) { event = getEventNameFromEventNameParam(bean, context); } if (event == null) { event = getEventNameFromRequestParams(bean, context); } if (event == null) { event = getEventNameFromPath(bean, context); } return event; } /** * Checks a special request attribute to get the event name. This attribute * may be set when the presence of the original request parameters on a * forwarded request makes it difficult to determine which event to fire. * * @param bean the ActionBean type bound to the request * @param context the ActionBeanContect for the current request * @return the name of the event submitted, or null if none can be found * @see StripesConstants#REQ_ATTR_EVENT_NAME */ protected String getEventNameFromRequestAttribute( Class<? extends ActionBean> bean, ActionBeanContext context) { return (String) context.getRequest().getAttribute( StripesConstants.REQ_ATTR_EVENT_NAME); } /** * Loops through the set of known events for the ActionBean to see if the * event names are present as parameter names in the request. Returns the * first event name found in the request, or null if none is found. * * @param bean the ActionBean type bound to the request * @param context the ActionBeanContext for the current request * @return String the name of the event submitted, or null if none can be * found */ @SuppressWarnings("unchecked") protected String getEventNameFromRequestParams(Class<? extends ActionBean> bean, ActionBeanContext context) { List<String> eventParams = new ArrayList<String>(); Map<String, String[]> parameterMap = context.getRequest().getParameterMap(); for (String event : this.eventMappings.get(bean).keySet()) { if (parameterMap.containsKey(event) || parameterMap.containsKey(event + ".x")) { eventParams.add(event); } } if (eventParams.size() == 0) { return null; } else if (eventParams.size() == 1) { return eventParams.get(0); } else { throw new StripesRuntimeException("Multiple event parameters " + eventParams + " are present in this request. Only one event parameter may be specified " + "per request. Otherwise, Stripes would be unable to determine which event " + "to execute."); } } /** * Looks to see if there is extra path information beyond simply the url * binding of the bean. If it does and the next /-separated part of the path * matches one of the known event names for the bean, that event name will * be returned, otherwise null. * * @param bean the ActionBean type bound to the request * @param context the ActionBeanContect for the current request * @return String the name of the event submitted, or null if none can be * found */ protected String getEventNameFromPath(Class<? extends ActionBean> bean, ActionBeanContext context) { Map<String, Method> mappings = this.eventMappings.get(bean); String path = HttpUtil.getRequestedPath(context.getRequest()); UrlBinding prototype = getUrlBindingFactory().getBindingPrototype(path); String binding = prototype == null ? null : prototype.getPath(); if (binding != null && path.length() != binding.length()) { String extra = path.substring(binding.length() + 1); int index = extra.indexOf("/"); String event = extra.substring(0, (index != -1) ? index : extra.length()); if (mappings.containsKey(event)) { return event; } } return null; } /** * Looks to see if there is a single non-empty parameter value for the * parameter name specified by {@link StripesConstants#URL_KEY_EVENT_NAME}. * If there is, and it matches a known event it is returned, otherwise * returns null. * * @param bean the ActionBean type bound to the request * @param context the ActionBeanContect for the current request * @return String the name of the event submitted, or null if none can be * found */ protected String getEventNameFromEventNameParam(Class<? extends ActionBean> bean, ActionBeanContext context) { String[] values = context.getRequest().getParameterValues(StripesConstants.URL_KEY_EVENT_NAME); String event = null; if (values != null && values.length == 1 && this.eventMappings.get(bean).containsKey(values[0])) { event = values[0]; } // Warn of non-backward-compatible behavior if (event != null) { try { String otherName = getEventNameFromRequestParams(bean, context); if (otherName != null && !otherName.equals(event)) { String[] otherValue = context.getRequest().getParameterValues(otherName); log.warn("The event name was specified by two request parameters: ", StripesConstants.URL_KEY_EVENT_NAME, "=", event, " and ", otherName, "=", Arrays.toString(otherValue), ". ", "As of Stripes 1.5, ", StripesConstants.URL_KEY_EVENT_NAME, " overrides all other request parameters."); } } catch (StripesRuntimeException e) { // Ignore this. It means there were too many event params, which is OK in this case. } } return event; } /** * Uses the Maps constructed earlier to locate the Method which can handle * the event. * * @param bean the subclass of ActionBean that is bound to the request. * @param eventName the name of the event being handled * @return a Method object representing the handling method. * @throws StripesServletException thrown when no method handles the named * event. */ public Method getHandler(Class<? extends ActionBean> bean, String eventName) throws StripesServletException { Map<String, Method> mappings = this.eventMappings.get(bean); Method handler = mappings.get(eventName); // If we haven't found a handler yet, then go through the mapping keys // again to see if we have a case-insensitive match. If we have exactly // one match, then use that, but give a warning. if (handler == null) { int eventNameCount = 0; for (String key : mappings.keySet()) { if (key.equalsIgnoreCase(eventName)) { eventNameCount++; handler = mappings.get(key); } } if (eventNameCount > 1) { throw new StripesServletException( "There is more than one handler which could match the " + " requested event name [" + eventName + "]. Check " + " your event name in the ActionBean to ensure it is " + "correct. [" + bean.getName() + "]. Known handler mappings are: " + mappings); } else { log.warn("Found event handler using a case-insensitive match. Check " + " the ActionBean event method name and request name " + " to make sure they match. [eventNameRequested=" + eventName + "] [ActionBean=" + bean.getName() + "]"); } } // If we could not find a handler then we should blow up quickly if (handler == null) { throw new StripesServletException( "Could not find handler method for event name [" + eventName + "] on class [" + bean.getName() + "]. Known handler mappings are: " + mappings); } return handler; } /** * Returns the Method that is the default handler for events in the * ActionBean class supplied. If only one handler method is defined in the * class, that is assumed to be the default. If there is more than one then * the method marked with @DefaultHandler will be returned. * * @param bean the ActionBean type bound to the request * @return Method object that should handle the request * @throws StripesServletException if no default handler could be located */ public Method getDefaultHandler(Class<? extends ActionBean> bean) throws StripesServletException { Map<String, Method> handlers = this.eventMappings.get(bean); if (handlers.size() == 1) { return handlers.values().iterator().next(); } else { Method handler = handlers.get(DEFAULT_HANDLER_KEY); if (handler != null) { return handler; } } // If we get this far, there is no sensible default! Kaboom! throw new StripesServletException("No default handler could be found for ActionBean of " + "type: " + bean.getName()); } /** * Provides subclasses with access to the configuration object. */ protected Configuration getConfiguration() { return this.configuration; } /** * Helper method to find implementations of ActionBean in the packages * specified in Configuration using the {@link ResolverUtil} class. * * @return a set of Class objects that represent subclasses of ActionBean */ protected Set<Class<? extends ActionBean>> findClasses() { BootstrapPropertyResolver bootstrap = getConfiguration().getBootstrapPropertyResolver(); String packages = bootstrap.getProperty(PACKAGES); if (packages == null) { throw new StripesRuntimeException( "You must supply a value for the configuration parameter '" + PACKAGES + "'. The " + "value should be a list of one or more package roots (comma separated) that are " + "to be scanned for ActionBean implementations. The packages specified and all " + "subpackages are examined for implementations of ActionBean." ); } String[] pkgs = StringUtil.standardSplit(packages); ResolverUtil<ActionBean> resolver = new ResolverUtil<ActionBean>(); resolver.findImplementations(ActionBean.class, pkgs); return resolver.getClasses(); } /** * Get all the classes implementing {@link ActionBean} that are recognized * by this {@link ActionResolver}. */ public Collection<Class<? extends ActionBean>> getActionBeanClasses() { return getUrlBindingFactory().getActionBeanClasses(); } public Class<? extends ActionBean> getActionBeanByName(String actionBeanName) { return actionBeansByName.get(actionBeanName); } }