/* 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.*; import net.sourceforge.stripes.config.Configuration; import net.sourceforge.stripes.exception.StripesServletException; import net.sourceforge.stripes.util.Literal; import net.sourceforge.stripes.util.Log; import javax.servlet.ServletContext; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.net.MalformedURLException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; /** * <p>An ActionResolver that uses the names of classes and methods to generate sensible default * URL bindings and event names respectively. Extends the default * {@link AnnotatedClassActionResolver}, and is fully backward compatible. Any classes and * methods that are annotated with {@link net.sourceforge.stripes.action.UrlBinding} and * {@link net.sourceforge.stripes.action.HandlesEvent} will retain the bindings specified in * those annotations. In the case when an annotation is absent then a default binding is * generated.</p> * * <p>The generation of ActionBean URL bindings is done by taking the class name and removing * any extraneous packages at the front of the name, removing the strings "Action" and "Bean" * from the end of the name, substituting slashes for periods and appending a suffix (.action by * default). The set of packages that are trimmed is specified by the * {@code getBasePackages()} method. By default this method returns the set * [web, www, stripes, action]. These packages (and their parents) are removed from the * class name. E.g. {@code com.myco.web.foo.BarActionBean} would become {@code foo.BarActionBean}. * Continuing on, the list of Action Bean suffixes, specified by the {@code getActionBeanSuffixes()} * method, are trimmed from the end of the Action Bean class name. With the defaults, * [Bean, Action], we would trim {@code foo.BarActionBean} further to {@code foo.Bar}, and then * translate it to {@code /foo/Bar}. Lastly the suffix returned by {@code getBindingSuffix()} * is appended, giving the binding {@code /foo/Bar.action}.</p> * * <p>The translation of class names into URL bindings is designed to be easy to override and * customize. To that end you can easily change how this translation is done by overriding * {@code getBasePackages()} and/or {@code getBindingSuffix()}, or completely customize the * behaviour by overriding {@code getUrlBinding(String)}.</p> * * <p>Mapping of method names to event names is simpler. Again the parent class is delegated to * in case the method is annotated. If it is not, and the method is a concrete public method that * returns a Resolution (or subclass thereof) it is mapped to an event of the same name as the * method. So an un-annotated method "{@code public Resolution view()}" is mapped to an event * called "view". It should be noted that there is no special method name that signifies the * default handler. If there is more than one handler and you require a default handler you * must still mark the default method with {@code @DefaultHandler}.</p> * * <p>Another useful feature of the NameBasedActionResolver is that when a request arrives for a * URL that is not bound to an ActionBean the resolver will attempt to map the request to a view * and return a 'dummy' ActionBean that will take the user to the view. The exact behaviour is * modifiable by overriding one or more of * {@link #handleActionBeanNotFound(ActionBeanContext, String)}, {@link #findView(String)} or * {@link #getFindViewAttempts(String)}. The default behaviour is to map the URL being requested * to three potential JSP names/paths, check for the existence of a JSP at those locations and if * one exists then to return an ActionBean that will render the view. For example if a user * requested '/account/ViewAccount.action' but an ActionBean does not yet exist bound to that URL, * the resolver will check for JSPs in the following order:</p> * * <ul> * <li>/account/ViewAccount.jsp</li> * <li>/account/viewAccount.jsp</li> * <li>/account/view_account.jsp</li> * </ul> * * <p>The value of this approach comes from the fact that by default all pages can appear to have * a pre-action whether they actually have one or not. In the above can you might chose to link * to {@code /account/ViewAccount.action} even though you know that no action exists and you want * to navigate directly to a page. This way, if you later decide you do need a pre-action for any * reason you can simply code the ActionBean and be done. No URLs or links need to be modified * and all requests to {@code /account/ViewAccount.action} will flow through the ActionBean.</p> * * @author Tim Fennell * @since Stripes 1.2 */ public class NameBasedActionResolver extends AnnotatedClassActionResolver { /** * Default set of packages (web, www, stripes, action) to be removed from the front * of class names when translating them to URL bindings. */ public static final Set<String> BASE_PACKAGES = Collections.unmodifiableSet(Literal.set("web", "www", "stripes", "action")); /** Default suffix (.action) to add to URL bindings.*/ public static final String DEFAULT_BINDING_SUFFIX = ".action"; /** * Default list of suffixes (Bean, Action) to remove to the end of the Action Bean class name. */ public static final List<String> DEFAULT_ACTION_BEAN_SUFFIXES = Collections.unmodifiableList(Literal.list("Bean", "Action")); /** Log instance used to log information from this class. */ private static final Log log = Log.getInstance(NameBasedActionResolver.class); /** * First invokes the parent classes init() method and then quietly adds a specialized * ActionBean to the set of ActionBeans the resolver is managing. The "specialized" bean * is one that is used when a bean is not bound to a URL, to then forward the user to * an appropriate view if one exists. */ @Override public void init(Configuration configuration) throws Exception { super.init(configuration); addActionBean(DefaultViewActionBean.class); } /** * <p>Finds or generates the URL binding for the class supplied. First delegates to the parent * class to see if an annotated url binding is present. If not, the class name is taken * and translated into a URL binding using {@code getUrlBinding(String name)}.</p> * * @param clazz a Class representing an ActionBean * @return the String URL binding for the ActionBean */ @Override public String getUrlBinding(Class<? extends ActionBean> clazz) { String binding = super.getUrlBinding(clazz); // If there's no annotated binding, and the class is concrete if (binding == null && !Modifier.isAbstract(clazz.getModifiers())) { binding = getUrlBinding(clazz.getName()); } return binding; } /** * Takes a class name and translates it into a URL binding by removing extraneous package names, * removing Action, Bean, or ActionBean from the end of the class name if present, replacing * periods with slashes, and appending a standard suffix as supplied by * {@link net.sourceforge.stripes.controller.NameBasedActionResolver#getBindingSuffix()}.</p> * * <p>For example the class {@code com.myco.web.action.user.RegisterActionBean} would be * translated to {@code /user/Register.action}. The behaviour of this method can be * overridden either directly or by overriding the methods {@code getBindingSuffix()} and * {@code getBasePackages()} which are used by this method.</p> * * @param name the name of the class to create a binding for * @return a String URL binding for the class * */ protected String getUrlBinding(String name) { // Chop off the packages up until (and including) any base package for (String base : getBasePackages()) { int i = name.indexOf("." + base + "."); if (i != -1) { name = name.substring(i + base.length() + 1); } else if (name.startsWith(base + ".")) { name = name.substring(base.length()); } } // If it ends in any of the Action Bean suffixes, remove them for (String suffix : getActionBeanSuffixes()) { if (name.endsWith(suffix)) { name = name.substring(0, name.length() - suffix.length()); } } // Replace periods with slashes and make sure it starts with one name = name.replace('.', '/'); if (!name.startsWith("/")) { name = "/" + name; } // Lastly add the suffix name += getBindingSuffix(); return name; } /** * Returns a set of package names (fully qualified or not) that should be removed * from the start of a classname before translating the name into a URL Binding. By default * returns "web", "www", "stripes" and "action". * * @return a non-null set of String package names. */ protected Set<String> getBasePackages() { return BASE_PACKAGES; } /** * Returns a non-null String suffix to be used when constructing URL bindings. The * default is ".action". */ protected String getBindingSuffix() { return DEFAULT_BINDING_SUFFIX; } /** * Returns a list of suffixes to be removed from the end of the Action Bean class name, if present. * The defaults are ["Bean", "Action"]. * * @since Stripes 1.5 */ protected List<String> getActionBeanSuffixes() { return DEFAULT_ACTION_BEAN_SUFFIXES; } /** * First checks with the super class to see if an annotated event name is present, and if * not then returns the name of the handler method itself. Will return null for methods * that do not return a resolution or are non-public or abstract. * * @param handler a method which may or may not be a handler method * @return the name of the event handled, or null */ @Override public String getHandledEvent(Method handler) { String name = super.getHandledEvent(handler); // If the method isn't annotated, but does return a resolution and is // not abstract (we already know it's public) then use the method name if ( name == null && !Modifier.isAbstract(handler.getModifiers()) && Resolution.class.isAssignableFrom(handler.getReturnType()) && handler.getParameterTypes().length == 0) { name = handler.getName(); } if (name == null && isAsyncEventHandler(handler)) { name = handler.getName(); } return name; } public static boolean isAsyncEventHandler(Method handler) { if (!Modifier.isAbstract(handler.getModifiers()) && handler.getReturnType().equals(Void.TYPE) && handler.getParameterTypes().length == 1) { // look at arg type Class<?> pType = handler.getParameterTypes()[0]; return AsyncResponse.class.isAssignableFrom(pType); } return false; } /** * <p>Overridden to trap the exception that is thrown when a URL cannot be mapped to an * ActionBean and then attempt to construct a dummy ActionBean that will forward the * user to an appropriate view. In an exception is caught then the method * {@link #handleActionBeanNotFound(ActionBeanContext, String)} is invoked to handle * the exception.</p> * * @param context the ActionBeanContext of the current request * @param urlBinding the urlBinding determined for the current request * @return an ActionBean if there is an appropriate way to handle the request * @throws StripesServletException if no ActionBean or alternate strategy can be found */ @Override public ActionBean getActionBean(ActionBeanContext context, String urlBinding) throws StripesServletException { try { return super.getActionBean(context, urlBinding); } catch (StripesServletException sse) { ActionBean bean = handleActionBeanNotFound(context, urlBinding); if (bean != null) { setActionBeanContext(bean, context); assertGetContextWorks(bean); return bean; } else { throw sse; } } } /** * Invoked when no appropriate ActionBean can be located. Attempts to locate a view that is * appropriate for this request by calling {@link #findView(String)}. If a view is found * then a dummy ActionBean is constructed that will send the user to the view. If no appropriate * view is found then null is returned. * * @param context the ActionBeanContext of the current request * @param urlBinding the urlBinding determined for the current request * @return an ActionBean that will render a view for the user, or null * @since Stripes 1.3 */ protected ActionBean handleActionBeanNotFound(ActionBeanContext context, String urlBinding) { ActionBean bean = null; Resolution view = findView(urlBinding); if (view != null) { log.debug("Could not find an ActionBean bound to '", urlBinding, "', but found a view ", "at '", view, "'. Forwarding the user there instead."); bean = new DefaultViewActionBean(view); } return bean; } /** * <p>Attempts to locate a default view for the urlBinding provided and return a * ForwardResolution that will take the user to the view. Looks for views by using the * list of attempts returned by {@link #getFindViewAttempts(String)}. * * <p>For each view name derived a check is performed using * {@link ServletContext#getResource(String)} to see if there is a file located at that URL. * Only if a file actually exists will a Resolution be returned.</p> * * <p>Can be overridden to provide a different kind of resolution. It is strongly recommended * when overriding this method to check for the actual existence of views prior to manufacturing * a resolution in order not to cause confusion when URLs are mistyped.</p> * * @param urlBinding the url being accessed by the client in the current request * @return a Resolution if a default view can be found, or null otherwise * @since Stripes 1.3 */ protected Resolution findView(String urlBinding) { List<String> attempts = getFindViewAttempts(urlBinding); ServletContext ctx = StripesFilter.getConfiguration() .getBootstrapPropertyResolver().getFilterConfig().getServletContext(); for (String jsp : attempts) { try { // This will try /account/ViewAccount.jsp if (ctx.getResource(jsp) != null) { return new ForwardResolution(jsp); } } catch (MalformedURLException mue) { } } return null; } /** * <p>Returns the list of attempts to locate a default view for the urlBinding provided. * Generates attempts for views by converting the incoming urlBinding with the following rules. * For example if the urlBinding is '/account/ViewAccount.action' the following views will be * returned in order:</p> * * <ul> * <li>/account/ViewAccount.jsp</li> * <li>/account/viewAccount.jsp</li> * <li>/account/view_account.jsp</li> * </ul> * * <p>Can be overridden to look for views with a different pattern.</p> * * @param urlBinding the url being accessed by the client in the current request * @since Stripes 1.5 */ protected List<String> getFindViewAttempts(String urlBinding) { List<String> attempts = new ArrayList<String>(3); int lastPeriod = urlBinding.lastIndexOf('.'); String path = urlBinding.substring(0, urlBinding.lastIndexOf("/") + 1); String name = (lastPeriod >= path.length()) ? urlBinding.substring(path.length(), lastPeriod) : urlBinding.substring(path.length()); if (name.length() > 0) { // This will try /account/ViewAccount.jsp attempts.add(path + name + ".jsp"); // This will try /account/viewAccount.jsp name = Character.toLowerCase(name.charAt(0)) + name.substring(1); attempts.add(path + name + ".jsp"); // And finally this will try /account/view_account.jsp StringBuilder builder = new StringBuilder(); for (int i = 0; i < name.length(); ++i) { char ch = name.charAt(i); if (Character.isUpperCase(ch)) { builder.append("_"); builder.append(Character.toLowerCase(ch)); } else { builder.append(ch); } } attempts.add(path + builder.toString() + ".jsp"); } return attempts; } /** * In addition to the {@link net.sourceforge.stripes.action.ActionBean} class simple name, also add aliases for * short hand names. For instance, ManageUsersActionBean would get: * <ul> * <li>ManageUsersActionBean (simple name)</li> * <li>ManageUsersAction</li> * <li>ManageUsers</li> * </ul> */ @Override protected void addBeanNameMappings() { super.addBeanNameMappings(); Set<String> generatedAliases = new HashSet<String>(); Set<String> duplicateAliases = new HashSet<String>(); for(Class<? extends ActionBean> clazz : getActionBeanClasses()) { String name = clazz.getSimpleName(); for (String suffix : getActionBeanSuffixes()) { if (name.endsWith(suffix)) { name = name.substring(0, name.length() - suffix.length()); if (generatedAliases.contains(name)) { log.warn("Found multiple action beans with same bean name ", name, ". You will need to " + "reference these action beans by their fully qualified names"); duplicateAliases.add(name); continue; } generatedAliases.add(name); actionBeansByName.put(name, clazz); } } } // Remove any duplicate aliases that were found for(String duplicateAlias : duplicateAliases) { actionBeansByName.remove(duplicateAlias); } } }