Pretty URLs (Rails style) with WebWork

Posted by ryan
at 10:39 AM on Friday, January 13, 2006



I recently had to develop a way to provide for non-parameterized URLs in a WebWork (2.1.x) application. Non-parameterized URLs are those that don’t have the endless list of ?key1=value1&key2=value2 parameters attached to the URL and instead have the nice tidy Rails style URL like ../key1/value1/key2/value2.

Besides being much prettier, this style of URL also helps with search engines as some don’t like to index pages with too many parameters specified (or with an “&id” parameter). When the parameters appear as part of the URL, everybody is happy.

WebWork v2.1.x doesn’t exactly have a nice plugin point to introduce this behavior, so the actual dispatcher servlet needs to be modified to parse URLs of that format. Just so happens there is a very undocumented version of the dispatcher servlet called com.opensymphony.webwork.dispatcher.CoolUriServletDispatcher that attempts to provide just such behavior (thanks, Jim). However, it puked up some String.substring() index out of bounds exceptions when I gave it a spin. I have a feeling its parsing of the URL was very dependent on the request mapping configuration in the web.xml (which is a hairy task). So, what else was I to do but create my own?

My version is pretty similar to the CoolUriServletDispatcher that’s included with WebWork except that it has some enhancements:

  1. doesn’t throw a String.subtring index out of bounds exception on my deployment ;)
  2. can handle conventional actionName.action (or any other extension) url formats
  3. assumes a format of actionName/value to set the id property on the action and NOT to set the id property on the actionName property of the action
  4. can simultaneously handle both pretty url parameters (/key1/value1…) and conventional parameters (/key1/value1?key2=value2)
Here are some quick usage guidelines:
  • Conventional requests will be handled as always:
    http://HOST/CONTEXT/ACTION_NAME.action?id=1&key1=value1
    will be handled by the ACTION_NAME action with the given parameters
  • Pretty urls in this format (when using both conventional and pretty url formats):
    http://HOST/CONTEXT/link/ACTION_NAME/id/1/key1/value1
    will be handled by the ACTION_NAME action with parameters (id=1, key1=value1)
  • Pretty urls in this format (when using both conventional and pretty url formats):
    http://HOST/CONTEXT/link/ACTION_NAME/id/1?key1=value1
    will be handled by the ACTION_NAME action with parameters (id=1, key1=value1)
  • Pretty urls in this format (when using both conventional and pretty url formats):
    http://HOST/CONTEXT/link/ACTION_NAME/1?key1=value1
    will be handled by the ACTION_NAME action with parameters (id=1, key1=value1)
  • When only the pretty url format is used and all requests are mapped to this dispatcher servlet, the /link portion can be removed and you will see the same functionality:
    http://HOST/CONTEXT/ACTION_NAME/id/1/key1/value1
    will be handled by the ACTION_NAME action with parameters (id=1, key1=value1)
    Same goes for: http://HOST/CONTEXT/ACTION_NAME/1/key1/value1

I’ve attempted to make the javadocs quite complete, so I’ll point you there for further documentation: UriMappingServletDispatcher javadocs.

And here is the actual servlet file (actual source is attached at the end of this entry as well):

UriMappingServletDispatcher.java (for Java 5)
UriMappingServletDispatcher.java (for Java 1.4 and below)

It should be noted that the recently released WebWork v2.2 does have an ActionMapper plugin point and a RestfulActionMapper implementation that does this very thing, so go use that if you can.



Actual source:

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.security.AccessController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;

import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import sun.security.action.GetPropertyAction;

import com.opensymphony.webwork.dispatcher.ServletDispatcher;
public class UriMappingServletDispatcher extends ServletDispatcher {

    private static final long serialVersionUID = 1L;

    private static final String DEFAULT_ENCODING = (String) AccessController
            .doPrivileged(new GetPropertyAction("file.encoding"));

    public static final String URI_IGNORE_CONFIG_KEY = "ignoreURIPortion";

    private String ignoreURIPortion = "";

    /*
     * (non-Javadoc)
     * 
     * @see javax.servlet.Servlet#init(javax.servlet.ServletConfig)
     */
    public void init(ServletConfig servletConfig) throws ServletException {
        super.init(servletConfig);

        // Get if we need to ignore any portion of the URL
        String ignore = servletConfig.getInitParameter(URI_IGNORE_CONFIG_KEY);
        if (ignore != null) {
            ignoreURIPortion = ignore;
        }
    }

    /*
     * (non-Javadoc)
     * 
     * @see javax.servlet.http.HttpServlet#service(javax.servlet.http.HttpServletRequest,
     *      javax.servlet.http.HttpServletResponse)
     */
    public void service(HttpServletRequest request, HttpServletResponse response)
            throws ServletException {

        // Determine action name
        String actionName = getActionName(getAdjustedString(request
                .getServletPath()));
        if (actionName != null && !"".equals(actionName)) {
            serviceVanillaRequest(request, response, actionName);
        } else {
            actionName = getUriMappedActionName(request);
            serviceUriMappedRequest(request, response, actionName);
        }
    }

    /**
     * This request has been identified as not being a vanilla request, so
     * handle it as though it's a URI mapped request.
     * 
     * @param request
     * @param response
     * @param actionName
     */
    protected void serviceUriMappedRequest(HttpServletRequest request,
            HttpServletResponse response, String actionName) {

        // Place to store our parsed parameters
        Map parameters = new HashMap();

        // Get the part of the URL from the actionName onwards
        String requestURI = getAdjustedString(request.getRequestURI());
        String paramPortion = requestURI.substring(requestURI
                .indexOf(actionName), requestURI.length());
        StringTokenizer st = new StringTokenizer(paramPortion, "/");

        // Collect the parameters
        List paramsList = new ArrayList(st.countTokens());
        while (st.hasMoreTokens()) {
            try {
                paramsList.add(URLDecoder.decode(st.nextToken(),
                        DEFAULT_ENCODING));
            } catch (UnsupportedEncodingException e) {
                paramsList.add(st.nextToken());
            }
        }

        // Which tokens the params start on. Default is the the second,
        // since the first token is the action
        int tokenStart = 1;

        // If there are an even number of params then we can assume that
        // the first token is the name of the action and the second
        // is the id property to set on that action.
        if (paramsList.size() % 2 == 0) {
            parameters.put("id", paramsList.get(1));
            tokenStart = 2;
        }

        // Parse the rest of the parameters as key/value/key/value...
        for (int i = tokenStart; i < paramsList.size(); i = i + 2) {
            parameters.put(paramsList.get(i), paramsList.get(i + 1));
        }

        // Add in any 'normal' request parameters that are present (which
        // will override the url mapped ones)
        parameters.putAll(request.getParameterMap());

        // Pass of our parameters and action on to the default processing
        // This part is wholly copied from
        // com.opensymphony.webwork.dispatcher.CoolUriServletDispatcher
        try {
            request = wrapRequest(request);
            serviceAction(request, response, ””, actionName,
                    getRequestMap(request), parameters, getSessionMap(request),
                    getApplicationMap());
        } catch (IOException e) {
            String message = “Could not wrap servlet request with MultipartRequestWrapper!”;
            sendError(request, response,
                    HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
                    new ServletException(message, e));
        }

    }

    /*
     * This request has been identified as being a vanilla request:
     * actionName.action?key=value&key=value…., handle it appropriately
     * 
     * @param request
     * @param response
     * @param actionName
     */
    protected void serviceVanillaRequest(HttpServletRequest request,
            HttpServletResponse response, String actionName) {
        try {
            serviceAction(wrapRequest(request), response,
                    getNameSpace(request), actionName, getRequestMap(request),
                    getParameterMap(request), getSessionMap(request),
                    getApplicationMap());
        } catch (IOException e) {
            String message = “Could not wrap servlet request with MultipartRequestWrapper!”;
            sendError(request, response,
                    HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
                    new ServletException(message, e));
        }
    }

    /*
     * The action could not be determined when the vanilla action.xxx format was
     * used – revert to other more manual means to determine the action name.
     * 
     * @param request
     * @return
     /
    protected String getUriMappedActionName(HttpServletRequest request) {

        // Safest way seems to be to take the request URI, and strip off the
        // context path from the beginning, then strip off everything after the
        // first slash
        String uri = request.getRequestURI();
        String contextPath = request.getContextPath();
        String servletPath = request.getServletPath();

        // Strip off the context and servlet paths to get at our action string
        String baseUri = uri.substring(contextPath.length(), uri.length());
        baseUri = baseUri.substring(servletPath.length(), baseUri.length());

        // Strip off trailing args past the action name (and not
        // including leftover leading ”/”)
        int trailingSlash = baseUri.indexOf(”/”, 1);
        baseUri = baseUri.substring(1, (trailingSlash > 0 ? trailingSlash
                : baseUri.length()));
        return baseUri;
    }

    /*
     * Get the portion of the request URI that should be analyzed for action
     * names, parameters etc… This is basically the URI minus the text
     * specified in the “ignoreURIPortion” servlet init param. (only takes out
     * the first occurance of the string – override for other functionality)
     * 
     * @param request
     * @return
     */
    protected String getAdjustedString(String adjustable) {
        return adjustable.replace(ignoreURIPortion, ””);
    }
}

Comments

Leave a response

  1. Jerome GagnerMay 25, 2006 @ 04:27 AM
    Wow, this is great! Exactly what I was looking for! Thanks man! Is there a performance hit?