Saturday, November 10, 2007

Friendly URL's in JSF

Introduction

Believe it or not, but there are two generic kinds of URL's: dirty URL's and friendly URL's.

Dirty URL's:
Example: /index.php?a=123&b=456&c=3dGySMoi&x=FgSDRt4
Pros: easy to implement 1:1 on back-end, high portable
Cons: difficult to mind, easy to hack, not indexed by most searchengines

Friendly URL's:
Example: /development/java/friendly-urls-in-jsf
Pros: easy to mind, hard to hack, indexed by all searchengines
Cons: difficult to implement 1:1 on back-end, less portable

To combine the best of both worlds it's a good practice to strictly use dirty URL's for the back-end functionality and add an extra mapping layer to be able to use Friendly URL's. In a Java web application environment this is relatively easy to implement using a simple filter which redirects unfriendly URL's to friendly URL's and which dispatches friendly URL's into unfriendly URL's. The redirect has one concern in JSF: the faces messages will be lost in the new request. Fortunately there exists a phase listener which restores those faces messages for a PRG pattern: POST-Redirect-GET pattern. Lot of its code can be reused and you will also "automagically" get the benefits of a PRG pattern!

Back to top

Filter, PhaseListener, Managed Bean

To introduce friendly URL's in an JSF environment we need at least three things:

  1. A FriendlyUrlFilter filter which intercepts on URL's with an unfriendly URL prefix and friendly URL prefix and will respectively redirect or dispatch the request. The goal is that friendly URL's are visible in the address bar all the time while the unfriendly URL is used in the dispatched page.
  2. A FriendlyUrlListener phase listener which redirects JSF POST requests to GET requests with friendly URL. The main purpose is that all faces messages will be restored in the subsequent GET request.
  3. A FriendlyUrlAction managed bean which stores Friendly URL action parameters in the request scope so that it is accessible by other managed beans and the phase listeners in the faces context.

Here is the complete code of the three above mentioned classes and their appropriate configuration. The stuff is tested in a Java EE 5.0 environment with Tomcat 6.0 with Servlet 2.5, JSP 2.1, JSTL 1.2 and JSF 1.2_05.


net.balusc.webapp.FriendlyUrlFilter

/*
 * net/balusc/webapp/FriendlyUrlFilter.java
 * 
 * Copyright (C) 2007 BalusC
 * 
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation; either version 2 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License along with this program; if
 * not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 * 02110-1301, USA.
 */

package net.balusc.webapp;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * This filter class intercepts on URL's with an unfriendly URL prefix and friendly URL prefix and
 * will respectively redirect or dispatch the request. The goal is that friendly URL's are visible
 * in the address bar all the time while the unfriendly URL is used in the dispatched page.
 * <p>
 * This filter should be configured in the web.xml as follows:
 * <pre>
 * &lt;filter&gt;
 *     &lt;description&gt;
 *         This filter class intercepts on URL's with an unfriendly URL prefix and friendly URL 
 *         prefix and will respectively redirect or dispatch the request. The goal is that friendly
 *         URL's are visible in the address bar all the time while the unfriendly URL is used in
 *         the dispatched page.
 *     &lt;/description&gt;
 *     &lt;filter-name&gt;friendlyUrlFilter&lt;/filter-name&gt;
 *     &lt;filter-class&gt;net.balusc.webapp.FriendlyUrlFilter&lt;/filter-class&gt;
 *     &lt;init-param&gt;
 *         &lt;description&gt;
 *             Sets the friendly URL prefix to filter on. There should also be a filter mapping
 *             with an url-pattern on 'friendlyUrlPrefix/*'.
 *             This parameter is required and should start with a '/'.
 *         &lt;/description&gt;
 *         &lt;param-name&gt;friendlyUrlPrefix&lt;/param-name&gt;
 *         &lt;param-value&gt;/action&lt;/param-value&gt;
 *     &lt;/init-param&gt;
 *     &lt;init-param&gt;
 *         &lt;description&gt;
 *             Sets the unfriendly URL prefix to filter on. There should also be a filter mapping
 *             with an url-pattern on 'unfriendlyUrlPrefix'.
 *             This parameter is required and should start with a '/'.
 *         &lt;/description&gt;
 *         &lt;param-name&gt;unfriendlyUrlPrefix&lt;/param-name&gt;
 *         &lt;param-value&gt;/action.jsf&lt;/param-value&gt;
 *     &lt;/init-param&gt;
 * &lt;/filter&gt;
 * &lt;filter-mapping&gt;
 *     &lt;filter-name&gt;friendlyUrlFilter&lt;/filter-name&gt;
 *     &lt;url-pattern&gt;/action/*&lt;/url-pattern&gt;
 * &lt;/filter-mapping&gt;
 * &lt;filter-mapping&gt;
 *     &lt;filter-name&gt;friendlyUrlFilter&lt;/filter-name&gt;
 *     &lt;url-pattern&gt;/action.jsf&lt;/url-pattern&gt;
 * &lt;/filter-mapping&gt;
 * </pre>
 * 
 * @author BalusC
 * @link http://balusc.blogspot.com/2007/11/friendly-urls-in-jsf.html
 */
public class FriendlyUrlFilter implements Filter {

    // Init ---------------------------------------------------------------------------------------

    private FilterConfig config;
    private String friendlyUrlPrefix;
    private String unfriendlyUrlPrefix;

    // Actions ------------------------------------------------------------------------------------

    /**
     * Configure the 'friendlyUrlPrefix' and 'unfriendlyUrlPrefix' parameters.
     * @see javax.servlet.Filter#init(javax.servlet.FilterConfig)
     */
    public void init(FilterConfig config) throws ServletException {

        // Init friendly URL prefix.
        String friendlyUrlPrefix = config.getInitParameter("friendlyUrlPrefix");
        if (friendlyUrlPrefix == null) {
            throw new ServletException(
                "FriendlyUrlFilter init-param 'friendlyUrlPrefix' is missing in web.xml.");
        }
        if (!friendlyUrlPrefix.startsWith("/")) {
            throw new ServletException(
                "FriendlyUrlFilter init-param 'friendlyUrlPrefix' value should start with '/'.");
        }
        this.friendlyUrlPrefix = friendlyUrlPrefix;

        // Init unfriendly URL prefix.
        String unfriendlyUrlPrefix = config.getInitParameter("unfriendlyUrlPrefix");
        if (unfriendlyUrlPrefix == null) {
            throw new ServletException(
                "FriendlyUrlFilter init-param 'unfriendlyUrlPrefix' is missing in web.xml.");
        }
        if (!unfriendlyUrlPrefix.startsWith("/")) {
            throw new ServletException(
                "FriendlyUrlFilter init-param 'unfriendlyUrlPrefix' value should start with '/'.");
        }
        this.unfriendlyUrlPrefix = unfriendlyUrlPrefix;

        // Set the URL prefixes in FriendlyUrlAction.
        ServletContext servletContext = config.getServletContext();
        FriendlyUrlAction.setFriendlyUrlPrefix(servletContext, this.friendlyUrlPrefix);
        FriendlyUrlAction.setUnfriendlyUrlPrefix(servletContext, this.unfriendlyUrlPrefix);

        // Store filter config.
        this.config = config;
    }

    /**
     * Check the path info and handle unfriendly URLs and friendly URLs accordingly.
     * @see javax.servlet.Filter#doFilter(
     *      javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)
     */
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException
    {
        // Cast ServletRequest and Response back to HttpServletRequest and Response.
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        // Check POST request.
        if ("POST".equals(httpServletRequest.getMethod())) {
            // POST requests will be handled by FriendlyUrlListener, so do nothing here.
            chain.doFilter(request, response);
            return;
        }

        // Get pathInfo. Technically we could use HttpServletRequest#getPathInfo() here, but it 
        // always returns null during filtering. API doc is not clear about this behaviour.
        String pathInfo = httpServletRequest.getRequestURI()
            .substring(httpServletRequest.getContextPath().length());

        // Check pathInfo.
        if (pathInfo.startsWith(unfriendlyUrlPrefix)) {

            // Unfriendly URL is used, translate it and redirect to friendly URL.
            redirectUnfriendlyUrl(httpServletRequest, httpServletResponse, pathInfo);
        
        } else if (pathInfo.startsWith(friendlyUrlPrefix)) {

            // Friendly URL is used, translate it and dispatch to unfriendly URL.
            dispatchFriendlyUrl(request, response, pathInfo);

        } else {

            // No match? This should never occur. If this happens, then the url-pattern 
            // configuration in the web.xml may need a review.
            throw new ServletException("FriendlyUrlFilter has encountered an unmatched request."
                + " Please review its url-pattern configuration in the web.xml.");
        }
    }

    /**
     * @see javax.servlet.Filter#destroy()
     */
    public void destroy() {
        // I am a boring method.
    }

    // Helpers ------------------------------------------------------------------------------------

    /**
     * Redirect unfriendly URL to friendly URL.
     * @param request The involved HttpServletRequest.
     * @param response The involved HttpServletResponse.
     * @param pathInfo The path info of the HttpServletRequest, currently only for logging purposes.
     * @throws IOException If redirecting fails.
     */
    private void redirectUnfriendlyUrl(
        HttpServletRequest request, HttpServletResponse response, String pathInfo)
            throws IOException
    {
        // Prepare friendly URL.
        StringBuilder url = new StringBuilder(request.getContextPath() + friendlyUrlPrefix);

        // Loop through FriendlyUrlAction parameters and append it to friendly URL.
        for (String param : FriendlyUrlAction.PARAMS) {
            String action = request.getParameter(param);

            if (action != null) {
                url.append("/" + action);
            } else {
                break;
            }
        }

        // Redirect to friendly URL (visible in address bar, redirect using HTTP 301).
        // Strictly use HTTP 301, otherwise the search bots will index the unfriendly URLs too.
        response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY); // 301.
        response.sendRedirect(url.toString());

        // Debug message. May be removed or replaced by your logging framework.
        System.out.println("FriendlyUrlFilter: " + pathInfo + " redirected to " + url);
    }

    /**
     * Dispatch friendly URL into unfriendly URL.
     * @param request The involved ServletRequest.
     * @param response The involved ServletResponse.
     * @param pathInfo The path info of the HttpServletRequest.
     * @throws ServletException If dispatching fails due to a servlet error.
     * @throws IOException If dispatching fails due to an I/O error.
     */
    private void dispatchFriendlyUrl(
        ServletRequest request, ServletResponse response, String pathInfo) 
            throws ServletException, IOException
    {
        // Prepare unfriendly URL.
        StringBuilder url = new StringBuilder(unfriendlyUrlPrefix);
        String[] actions = pathInfo.substring(friendlyUrlPrefix.length()).split("/");

        // Loop through friendly URL parameters and append it to unfriendly URL.
        for (int i = 1; i < actions.length && i <= FriendlyUrlAction.PARAMS.length; i++) {
            url.append((i == 1 ? "?" : "&") + FriendlyUrlAction.PARAMS[i - 1] + "=" + actions[i]);
        }

        // Dispatch the unfriendly url (silent, no new request, not visible in address bar).
        config.getServletContext().getRequestDispatcher(url.toString()).forward(request, response);

        // Debug message. May be removed or replaced by your logging framework.
        System.out.println("FriendlyUrlFilter: " + pathInfo + " dispatched into " + url);
    }

}

FriendlyUrlFilter configuration in web.xml:


    <filter>
        <description>
            This filter class intercepts on URL's with an unfriendly URL prefix and friendly URL 
            prefix and will respectively redirect or dispatch the request. The goal is that friendly
            URL's are visible in the address bar all the time while the unfriendly URL is used in
            the dispatched page.
        </description>
        <filter-name>friendlyUrlFilter</filter-name>
        <filter-class>net.balusc.webapp.FriendlyUrlFilter</filter-class>
        <init-param>
            <description>
                Sets the friendly URL prefix to filter on. There should also be a filter mapping
                with an url-pattern on 'friendlyUrlPrefix/*'.
                This parameter is required and should start with a '/'.
            </description>
            <param-name>friendlyUrlPrefix</param-name>
            <param-value>/action</param-value>
        </init-param>
        <init-param>
            <description>
                Sets the unfriendly URL prefix to filter on. There should also be a filter mapping
                with an url-pattern on 'unfriendlyUrlPrefix'.
                This parameter is required and should start with a '/'.
            </description>
            <param-name>unfriendlyUrlPrefix</param-name>
            <param-value>/action.jsf</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>friendlyUrlFilter</filter-name>
        <url-pattern>/action/*</url-pattern>
    </filter-mapping>
    <filter-mapping>
        <filter-name>friendlyUrlFilter</filter-name>
        <url-pattern>/action.jsf</url-pattern>
    </filter-mapping>

The FriendlyUrlFilter class is preconfigured in the web.xml that it intercepts on action.jsf (assuming that you have set the FacesServlet mapping to *.jsf, which is the best setting in my humble opinion). This will be used in the basic demonstration example shown later in this article. You can change the FriendlyUrlFilter configuration to your needs. If you find this XML piece too big, then just strip the description tags ;)


net.balusc.webapp.FriendlyUrlListener

/*
 * net/balusc/webapp/FriendlyUrlListener.java
 * 
 * Copyright (C) 2007 BalusC
 * 
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation; either version 2 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License along with this program; if
 * not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 * 02110-1301, USA.
 */

package net.balusc.webapp;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import javax.faces.FacesException;
import javax.faces.application.FacesMessage;
import javax.faces.context.ExternalContext;
import javax.faces.context.FacesContext;
import javax.faces.event.PhaseEvent;
import javax.faces.event.PhaseId;
import javax.faces.event.PhaseListener;
import javax.servlet.http.HttpServletRequest;

/**
 * This phase listener redirects JSF POST requests to GET requests with friendly URL. The main
 * purpose is that all faces messages will be restored in the subsequent GET request.
 * <p>
 * This phase listener should be configured in the faces-config.xml as follows:
 * <pre>
 * &lt;lifecycle&gt;
 *     &lt;phase-listener&gt;net.balusc.webapp.FriendlyUrlListener&lt;/phase-listener&gt;
 * &lt;/lifecycle&gt;
 * </pre>
 * 
 * @author BalusC
 * @link http://balusc.blogspot.com/2007/11/friendly-urls-in-jsf.html
 */
public class FriendlyUrlListener implements PhaseListener {

    // Init --------------------------------------------------------------------------------------

    private static final String ALL_FACES_MESSAGES_ID = "FriendlyUrlListener.allFacesMessages";

    // Actions -----------------------------------------------------------------------------------

    /**
     * @see javax.faces.event.PhaseListener#getPhaseId()
     */
    public PhaseId getPhaseId() {
        // Listen on render response only.
        return PhaseId.RENDER_RESPONSE;
    }

    /**
     * @see javax.faces.event.PhaseListener#beforePhase(javax.faces.event.PhaseEvent)
     */
    public void beforePhase(PhaseEvent event) {

        // Init.
        FacesContext facesContext = event.getFacesContext();
        ExternalContext externalContext = facesContext.getExternalContext();
        HttpServletRequest httpServletRequest = (HttpServletRequest) externalContext.getRequest();
        String pathInfo = httpServletRequest.getRequestURI()
            .substring(httpServletRequest.getContextPath().length());

        // Check POST request on unfriendly URL.
        if ("POST".equals(httpServletRequest.getMethod())
            && pathInfo.startsWith(FriendlyUrlAction.getUnfriendlyUrlPrefix()))
        {
            // Save facesmessages from POST request in session so that they'll
            // be avaiable on the subsequent GET request.
            saveFacesMessages(facesContext);

            // Unfriendly URL is used, translate it and redirect to friendly URL.
            redirectUnfriendlyUrl(externalContext);

        } else if (pathInfo.startsWith(FriendlyUrlAction.getFriendlyUrlPrefix())) {

            // Move any saved facesmessages back to the friendly URL GET request.
            restoreFacesMessages(facesContext);
        }
    }

    /**
     * @see javax.faces.event.PhaseListener#afterPhase(javax.faces.event.PhaseEvent)
     */
    public void afterPhase(PhaseEvent event) {
        // Do nothing.
    }

    // Helpers -----------------------------------------------------------------------------------

    /**
     * Save the facesmessages from the facescontext in the session.
     * @param facesContext The involved FacesContext.
     */
    private void saveFacesMessages(FacesContext facesContext) {

        // Prepare the facesmessages holder in the sessionmap.
        // The LinkedHashMap has precedence over HashMap, because in a LinkedHashMap
        // the FacesMessages will be kept in order, which can be very useful for
        // certain error and focus handlings. Anyway, it's just your design choice.
        Map<String, List<FacesMessage>> allFacesMessages = 
            new LinkedHashMap<String, List<FacesMessage>>();
        facesContext.getExternalContext().getSessionMap().put(
            ALL_FACES_MESSAGES_ID, allFacesMessages);

        // Get ID's of all components with facesmessages.
        Iterator<String> clientIds = facesContext.getClientIdsWithMessages();
        while (clientIds.hasNext()) {

            // Get the component ID.
            String clientId = clientIds.next();

            // Prepare the client facesmessages holder in the main facesmessages holder.
            List<FacesMessage> clientFacesMessages = new ArrayList<FacesMessage>();
            allFacesMessages.put(clientId, clientFacesMessages);

            // Get all messages from client and add this to the client facesmessage list.
            Iterator<FacesMessage> facesMessages = facesContext.getMessages(clientId);
            while (facesMessages.hasNext()) {
                clientFacesMessages.add(facesMessages.next());
            }
        }
    }

    /**
     * Redirect unfriendly URL to friendly URL.
     * @param externalContext The involved ExternalContext.
     */
    private void redirectUnfriendlyUrl(ExternalContext externalContext) {

        // Prepare.
        HttpServletRequest httpServletRequest = (HttpServletRequest) externalContext.getRequest();
        String contextPath = httpServletRequest.getContextPath();
        FriendlyUrlAction friendlyUrlAction = FriendlyUrlAction.getCurrentInstance();
        StringBuilder url = new StringBuilder();

        // Check if FriendlyUrlAction is invoked and the 1st parameter is available.
        if (friendlyUrlAction != null 
            && !isEmpty(friendlyUrlAction.getParameter(FriendlyUrlAction.PARAMS[0])))
        {
            // Prepare friendly URL.
            url.append(contextPath + FriendlyUrlAction.getFriendlyUrlPrefix());

            // Add the new actions to the URL.
            for (String param : FriendlyUrlAction.PARAMS) {
                String action = friendlyUrlAction.getParameter(param);

                if (!isEmpty(action)) {
                    url.append("/" + action);
                } else {
                    break;
                }
            }
        } else {
            // Keep the same action.
            url.append(httpServletRequest.getHeader("referer"));
        }

        // Redirect unfriendly URL to friendly URL.
        try {
            externalContext.redirect(url.toString());
            // HTTP 301 is not strictly required here as search bots generally don't index POST.
        } catch (IOException e) {
            throw new FacesException(
                "FriendlyUrlListener cannot redirect unfriendly URL to friendly URL.", e);
        }

        // Debug message. May be removed or replaced by your logging framework.
        String pathInfo = httpServletRequest.getRequestURI().substring(contextPath.length());
        System.out.println("FriendlyUrlListener: " + pathInfo + " redirected to " + url);
    }

    /**
     * Restore the facesmessages from the session and add them to the facescontext.
     * @param facesContext The involved FacesContext.
     */
    @SuppressWarnings("unchecked") // SessionMap is not generic.
    private void restoreFacesMessages(FacesContext facesContext) {

        // Remove the facesmessages holder from the sessionmap.
        Map<String, List<FacesMessage>> allFacesMessages = (Map<String, List<FacesMessage>>)
            facesContext.getExternalContext().getSessionMap().remove(ALL_FACES_MESSAGES_ID);

        if (allFacesMessages != null) {

            // Add the messages back to the new facescontext.
            for (String clientId : allFacesMessages.keySet()) {
                List<FacesMessage> clientFacesMessages = allFacesMessages.get(clientId);
                for (FacesMessage clientFacesMessage : clientFacesMessages) {
                    facesContext.addMessage(clientId, clientFacesMessage);
                }
            }
        }
    }

    // Utilities (should be refactored to public utility classes) ---------------------------------

    /**
     * Check if the given object is empty. Returns true if the object is null, or if it is an
     * instance of String and its trimmed length is zero, or if it is an instance of an ordinary
     * array and its length is zero, or if it is an instance of Collection and its size is zero,
     * or if it is an instance of Map and its size is zero, or if its String representation is
     * null or the trimmed length of its String representation is zero.
     * @param value The object to be determined on emptiness.
     * @return True if the given object value is empty.
     */
    public static boolean isEmpty(Object value) {
        if (value == null) {
            return true;
        } else if (value instanceof String) {
            return ((String) value).trim().length() == 0;
        } else if (value instanceof Object[]) {
            return ((Object[]) value).length == 0;
        } else if (value instanceof Collection<?>) {
            return ((Collection<?>) value).size() == 0;
        } else if (value instanceof Map<?, ?>) {
            return ((Map<?, ?>) value).size() == 0;
        } else {
            return value.toString() == null || value.toString().trim().length() == 0;
        }
    }

}

FriendlyUrlListener configuration in faces-config.xml:


    <lifecycle>
        <phase-listener>net.balusc.webapp.FriendlyUrlListener</phase-listener>
    </lifecycle>


net.balusc.webapp.FriendlyUrlAction

/*
 * net/balusc/webapp/FriendlyUrlAction.java
 * 
 * Copyright (C) 2007 BalusC
 * 
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation; either version 2 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License along with this program; if
 * not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 * 02110-1301, USA.
 */

package net.balusc.webapp;

import java.io.File;
import java.util.HashMap;
import java.util.Map;

import javax.faces.context.ExternalContext;
import javax.faces.context.FacesContext;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;

/**
 * This managed bean class stores Friendly URL action parameters in the request scope. 
 * <p>
 * This managed bean should be configured in the faces-config.xml as follows:
 * <pre>
 * &lt;managed-bean&gt;
 *     &lt;description&gt;
 *         This managed bean class stores Friendly URL action parameters in the request scope.
 *         The current Friendly URL demonstration uses three parameters: page, id and subid.
 *         Those should be mapped to the request parameters in the managed-property entries.
 *     &lt;/description&gt;
 *     &lt;managed-bean-name&gt;friendlyUrlAction&lt;/managed-bean-name&gt;
 *     &lt;managed-bean-class&gt;net.balusc.webapp.FriendlyUrlAction&lt;/managed-bean-class&gt;
 *     &lt;managed-bean-scope&gt;request&lt;/managed-bean-scope&gt;
 *     &lt;managed-property&gt;
 *         &lt;property-name&gt;page&lt;/property-name&gt;
 *         &lt;value&gt;#{param.page}&lt;/value&gt;
 *     &lt;/managed-property&gt;
 *     &lt;managed-property&gt;
 *         &lt;property-name&gt;id&lt;/property-name&gt;
 *         &lt;value&gt;#{param.id}&lt;/value&gt;
 *     &lt;/managed-property&gt;
 *     &lt;managed-property&gt;
 *         &lt;property-name&gt;subid&lt;/property-name&gt;
 *         &lt;value&gt;#{param.subid}&lt;/value&gt;
 *     &lt;/managed-property&gt;
 * &lt;/managed-bean&gt;
 * </pre>
 *  
 * @author BalusC
 * @link http://balusc.blogspot.com/2007/11/friendly-urls-in-jsf.html
 */
public class FriendlyUrlAction {

    // Init ---------------------------------------------------------------------------------------

    // Private defaults.
    private static final String DEFAULT_PAGE = "main";
    private static final String DEFAULT_PAGE_EXTENSION = ".jsp";
    
    // Private keys.
    private static final String MANAGED_BEAN_NAME = "friendlyUrlAction";
    private static final String FRIENDLY_URL_PREFIX_ID = "FriendlyUrlAction.friendlyUrlPrefix";
    private static final String UNFRIENDLY_URL_PREFIX_ID = "FriendlyUrlAction.unfriendlyUrlPrefix";
    private static final String FRIENDLY_URL_BASE_ID = "FriendlyUrlAction.friendlyUrlBase";

    // Parameter names.
    private static final String PARAM_PAGE = "page";
    private static final String PARAM_ID = "id";
    private static final String PARAM_SUBID = "subid";

    // Parameter map.
    private Map<String, String> params = new HashMap<String, String>();

    /** Contains all parameter names of FriendlyUrlAction. */
    public static final String[] PARAMS = new String[] { PARAM_PAGE, PARAM_ID, PARAM_SUBID };

    // Actions ------------------------------------------------------------------------------------

    /**
     * Returns the current managed bean instance of FriendlyUrlAction.
     * @return The current managed bean instance of FriendlyUrlAction.
     */
    public static FriendlyUrlAction getCurrentInstance() {
        // Get current instance of FriendlyUrlAction bean from the RequestMap of the FacesContext.
        return (FriendlyUrlAction) FacesContext.getCurrentInstance().getExternalContext()
            .getRequestMap().get(MANAGED_BEAN_NAME);
    }

    /**
     * Returns the value of FriendlyUrlFilter init-param 'friendlyUrlPrefix'.
     * @return The value of FriendlyUrlFilter init-param 'friendlyUrlPrefix'.
     */
    public static String getFriendlyUrlPrefix() {
        // ApplicationMap contains ServletContext attributes.
        return (String) FacesContext.getCurrentInstance().getExternalContext()
            .getApplicationMap().get(FRIENDLY_URL_PREFIX_ID);
    }

    /**
     * Returns the value of FriendlyUrlFilter init-param 'unfriendlyUrlPrefix'.
     * @return The value of FriendlyUrlFilter init-param 'unfriendlyUrlPrefix'.
     */
    public static String getUnfriendlyUrlPrefix() {
        // ApplicationMap contains ServletContext attributes.
        return (String) FacesContext.getCurrentInstance().getExternalContext()
            .getApplicationMap().get(UNFRIENDLY_URL_PREFIX_ID);
    }

    /**
     * FriendlyUrlFilter should set its init-param 'friendlyUrlPrefix' here.
     * @param servletContext The involved ServletContext to set the given friendlyUrlPrefix in.
     * @param friendlyUrlPrefix The value of FriendlyUrlFilter init-param 'friendlyUrlPrefix'.
     */
    static void setFriendlyUrlPrefix(ServletContext servletContext, String friendlyUrlPrefix) {
        servletContext.setAttribute(FRIENDLY_URL_PREFIX_ID, friendlyUrlPrefix);
    }

    /**
     * FriendlyUrlFilter should set its init-param 'unfriendlyUrlPrefix' here.
     * @param servletContext The involved ServletContext to set the given unfriendlyUrlPrefix in.
     * @param unfriendlyUrlPrefix The value of FriendlyUrlFilter init-param 'unfriendlyUrlPrefix'.
     */
    static void setUnfriendlyUrlPrefix(ServletContext servletContext, String unfriendlyUrlPrefix) {
        servletContext.setAttribute(UNFRIENDLY_URL_PREFIX_ID, unfriendlyUrlPrefix);
    }

    /**
     * Set the 'page' parameter of the current managed bean instance with the given value. In the 
     * current Friendly URL demonstration example this will set the include page which is to be 
     * retrieved in the render response.
     * @param page The include page to be used in the subsequent request.
     */
    public static void navigate(String page) {
        FriendlyUrlAction friendlyUrlAction = getCurrentInstance();

        if (friendlyUrlAction != null) {
            friendlyUrlAction.setPage(page);
        }
    }

    // Getters ------------------------------------------------------------------------------------

    public String getPage() {
        return params.get(PARAM_PAGE);
    }

    public String getId() {
        return params.get(PARAM_ID);
    }

    public String getSubid() {
        return params.get(PARAM_SUBID);
    }

    // Setters ------------------------------------------------------------------------------------

    public void setPage(String page) {
        params.put(PARAM_PAGE, page);
    }

    public void setId(String id) {
        params.put(PARAM_ID, id);
    }

    public void setSubid(String subid) {
        params.put(PARAM_SUBID, subid);
    }

    // Helpers ------------------------------------------------------------------------------------

    /**
     * Returns the parameter value associated with the given parameter name.
     * @param name The parameter name to retrieve the associated parameter value for.
     * @return The parameter value associated with the given parameter name.
     */
    public String getParameter(String name) {
        return params.get(name);
    }

    /**
     * Returns the base href for the action file of the current environment.
     * @return The base href for the action file of the current environment.
     */
    public String getBase() {
        ExternalContext externalContext = FacesContext.getCurrentInstance().getExternalContext();
        String base = (String) externalContext.getApplicationMap().get(FRIENDLY_URL_BASE_ID);

        if (base == null) {
            // Not stored in application map yet, so do it now.
            HttpServletRequest request = (HttpServletRequest) externalContext.getRequest();
            String url = request.getRequestURL().toString();
            base = url.substring(0, url.indexOf(getUnfriendlyUrlPrefix())) 
                + getFriendlyUrlPrefix() + "/";
            externalContext.getApplicationMap().put(FRIENDLY_URL_BASE_ID, base);
        }

        return base;
    }

    /**
     * Returns the include page of the current Friendly URL demonstration example, which is
     * basically the value of the 'page' parameter suffixed with '.jsp'. If the value of the 'page'
     * parameter is null or is non-existing, then it will return the default include page. 
     * @return The include page of the current Friendly URL demonstration example.
     */
    public String getIncludePage() {
        String page = params.get(PARAM_PAGE) + DEFAULT_PAGE_EXTENSION;
        ServletContext servletContext = (ServletContext) FacesContext.getCurrentInstance()
            .getExternalContext().getContext();

        if (!new File(servletContext.getRealPath(page)).exists()) {
            // Page does not exist on local disk.
            page = DEFAULT_PAGE + DEFAULT_PAGE_EXTENSION;
        }

        return page;
    }

}

FriendlyUrlAction configuration in faces-config.xml:


    <managed-bean>
        <description>
            This managed bean class stores Friendly URL action parameters in the request scope. 
            The current Friendly URL demonstration uses three parameters: page, id and subid.
            Those should be mapped to the request parameters in the managed-property entries.
        </description>
        <managed-bean-name>friendlyUrlAction</managed-bean-name>
        <managed-bean-class>net.balusc.webapp.FriendlyUrlAction</managed-bean-class>
        <managed-bean-scope>request</managed-bean-scope>
        <managed-property>
            <property-name>page</property-name>
            <value>#{param.page}</value>
        </managed-property>
        <managed-property>
            <property-name>id</property-name>
            <value>#{param.id}</value>
        </managed-property>
        <managed-property>
            <property-name>subid</property-name>
            <value>#{param.subid}</value>
        </managed-property>
    </managed-bean>

The FriendlyUrlAction class is prewritten so that it accepts three parameters in the following order: page, id and subid. The managed bean instance is preconfigured in the faces-config.xml that the request parameters will automatically be injected. This will be used in the basic demonstration example shown later in this article. You can change the FriendlyUrlAction parameters and its configuration to your needs.

Back to top

Basic demonstration example

To demonstrate the working of the above stuff, here are some more pieces of code (sigh, when does the hurting stop .. ). Basically there is one simple backing bean, one main JSF file and a bunch of JSF include files. The main JSF file, action.jsp, will be filtered by FriendlyUrlFilter (the FacesServlet mapping is set to *.jsf by the way). The first parameter of the FriendlyUrlAction, the page parameter, indicates the filename (without extension) of the JSF include file to be included in the main JSF file. The other two parameters are right now just for fun yet.

The demo backing bean mypackage.MyBean.

package mypackage;

import net.balusc.webapp.FriendlyUrlAction;

public class MyBean {

    // Init ---------------------------------------------------------------------------------------

    private String input;

    // Actions ------------------------------------------------------------------------------------

    public void action() {
        // Show input.
        System.out.println("MyBean input: " + input);

        // FriendlyUrlAction bean is also available here.
        FriendlyUrlAction friendlyUrlAction = FriendlyUrlAction.getCurrentInstance();
        System.out.println("MyBean FriendlyUrlAction id: " + friendlyUrlAction.getId());
        System.out.println("MyBean FriendlyUrlAction subid: " + friendlyUrlAction.getSubid());
        
        // Navigate to 'get'. Actually: use 'get.jsp' as include file of 'action.jsp'.
        FriendlyUrlAction.navigate("get");
    }

    // Getters ------------------------------------------------------------------------------------

    public String getInput() {
        return input;
    }

    // Setters ------------------------------------------------------------------------------------

    public void setInput(String input) {
        this.input = input;
    }

}

And its configuration in the faces-config.xml. Nothing shocking to see here.


    <managed-bean>
        <managed-bean-name>myBean</managed-bean-name>
        <managed-bean-class>mypackage.MyBean</managed-bean-class>
        <managed-bean-scope>request</managed-bean-scope>
    </managed-bean>

The main JSF file, action.jsp:

<%@ taglib uri="http://java.sun.com/jsf/core" prefix="f" %>
<%@ taglib uri="http://java.sun.com/jsf/html" prefix="h" %>

<f:view>
    <html>
        <head>
            <title>FriendlyUrl test</title>
            <base href="<h:outputText value="#{friendlyUrlAction.base}" />">
        </head>
        <body>
            <h1>Action</h1>
            <p>FriendlyUrlAction page: <b><h:outputText value="#{friendlyUrlAction.page}" /></b></p>
            <jsp:include page="#{friendlyUrlAction.includePage}" />
        </body>
    </html>
</f:view>

Please note the base href. This should be the URL pointing to the friendly URL action page, so that all relative local links in the tags will automatically become friendly URL parameters. E.g. <a href="get/friendly/url"> would be dispatched into action.jsf?page=get&id=friendly&subid=url then. Also note the use of unified EL in jsp:include, this only works in a JSP 2.1 + JSF 1.2 environment. If you're running an outdated environment, then this article might give new insights: Dynamic JSF subviews.

The main include file, main.jsp:

<%@ taglib uri="http://java.sun.com/jsf/core" prefix="f" %>
<%@ taglib uri="http://java.sun.com/jsf/html" prefix="h" %>

<f:subview id="main">
    <h2>Main</h2>
    <p>This is a default include page.</p>
    <p style="color: red;"><h:outputText 
        rendered="#{!empty friendlyUrlAction.page && friendlyUrlAction.page != 'main'}"
        value="Include page #{friendlyUrlAction.page}.jsp does not exist!" /></p>
    <p><a href="post">Click here to test Friendly URL POST request.</a></p> 
    <p><a href="get/friendly/url">Click here to test Friendly URL GET request.</a></p> 
</f:subview>

The POST test include file, post.jsp:

<%@ taglib uri="http://java.sun.com/jsf/core" prefix="f" %>
<%@ taglib uri="http://java.sun.com/jsf/html" prefix="h" %>

<f:subview id="post">
    <h2>Post</h2>
    <p>This is a POST test.</p>
    <p>The form below will change the include page to 'get.jsp' after submission.
    <br />The id and subid of FriendlyUrlAction will be sent as friendly URL parameters.
    <br />It also demonstrates that some bean value can be submitted simultaneously
    <br />and that any faces messages will be restored by FriendlyUrlListener.</p>
    <h:form id="form">
        <table>
            <tr>
                <td>FriendlyUrlAction id:</td>
                <td><h:inputText value="#{friendlyUrlAction.id}" /></td>
            </tr>
            <tr>
                <td>FriendlyUrlAction subid:</td>
                <td><h:inputText value="#{friendlyUrlAction.subid}" /></td>
            </tr>
            <tr>
                <td>Some required bean value:</td>
                <td>
                    <h:inputText id="input" value="#{myBean.input}" required="true" />
                    &nbsp;<h:message for="input" errorStyle="color: red;" />
                </td>
            </tr>
            <tr>
                <td></td>
                <td><h:commandButton value="submit" action="#{myBean.action}" /></td>
            </tr>
        </table>
        <h:inputHidden value="#{friendlyUrlAction.page}" />
    </h:form>
    <p><a href="main">Click here to go back to main page.</a></p> 
</f:subview>

Please note the h:inputHidden. If one of the properties of a request scoped bean isn't been passed through any UIInput, then you'll have to add it as a hidden input element so that it will be retained for the next request.

The GET test include file, get.jsp:

<%@ taglib uri="http://java.sun.com/jsf/core" prefix="f" %>
<%@ taglib uri="http://java.sun.com/jsf/html" prefix="h" %>

<f:subview id="get">
    <h2>Get</h2>
    <p>This is a GET test.</p>
    <p>Play somewhat with the path info of the URL in the address bar.
    <br />E.g. enter <a href="get/foo/bar">get/foo/bar</a> and you'll see that params changes.
    <br />Or enter <a href="../action.jsf?page=get&id=bar&subid=foo">
    action.jsf?page=get&id=bar&subid=foo</a> and you'll see that it become friendly.</p>
    <p>FriendlyUrlAction id: <b><h:outputText value="#{friendlyUrlAction.id}" /></b></p>
    <p>FriendlyUrlAction subid: <b><h:outputText value="#{friendlyUrlAction.subid}" /></b></p>
    <p><a href="main">Click here to go back to main page.</a></p> 
</f:subview>

Copy'n'paste the stuff, place all JSF files in the root of the webcontent, run it on http://localhost:8080/playground/action (assuming that your local development server runs at port 8080 and that the context root of your playground web application project is called 'playground') and see it wonderfully working!

Back to top

Copyright - GNU General Public License

(C) November 2007, BalusC

Thursday, November 8, 2007

MultipartFilter

Upload and store files

Downloading files is made relatively easy using a FileServlet, but uploading files is a bit harder. Entering/selecting the raw absolute file path in input type="text" and sending it to the server so that it can be used in a File object isn't going to work, as the server doesn't have access to the client's file system. That will work only if the server as well as the client runs on the same machine and that wouldn't occur in real life.

To browse and select a file for upload you need a input type="file" field in the form. As stated in the HTML specification you have to use the POST method and the enctype attribute of the form have to be set to "multipart/form-data".


<form action="myServlet" method="post" enctype="multipart/form-data">
    <input type="file" name="file" />
    <input type="submit" />
</form>

After submitting such a form the binary multipart form data is available in the HttpServletRequest#getInputStream(). For testing purposes you can read it using the following snippet:


BufferedReader reader = new BufferedReader(new InputStreamReader(request.getInputStream()));
String line = null;
while ((line = reader.readLine()) != null) {
    System.out.println(line);
}

Parsing such a stream requires precise background knowledge of how multipart form data is formed and structured. The regular form fields aren't available as parameter of the request (e.g. request.getParameter("name") will always return null), they are included in the binary stream. The uploaded files are also included in the binary stream. To create a perfect multipart parser you'll have to write a lot of code. But don't feel disappointed, there are lot of 3rd party multipart parsers available. A commonly used one is the Apache Commons FileUpload. It can parse the multipart form data into several FileItem objects (misleading class name by the way, I'd rather call it MultipartItem). You'll have to filter the parameters and files out yourself.

Back to top

MultipartFilter

To save you the effort, I've played around with it a while and wrote a MultipartFilter which automatically detects if the request is a multipart form data request, parses the request and store the parameters back in the HttpServletRequest and stores the uploaded files as attributes of the HttpServletRequest.

It makes use of the Apache Commons FileUpload API 1.2. So you need at least the following JAR's in the classpath, e.g. /WEB-INF/lib:

Here is the code. The stuff is tested in a Java EE 5.0 environment with Tomcat 6.0 with Servlet 2.5, JSP 2.1 and JSTL 1.2.

/*
 * net/balusc/webapp/MultipartFilter.java
 * 
 * Copyright (C) 2007 BalusC
 * 
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation; either version 2 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License along with this program; if
 * not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 * 02110-1301, USA.
 */

package net.balusc.webapp;

import java.io.IOException;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;

/**
 * Check for multipart HttpServletRequests and parse the multipart form data so that all regular
 * form fields are available in the parameterMap of the HttpServletRequest and that all form file
 * fields are available as attribute of the HttpServletRequest. The attribute value of a form file
 * field can be an instance of FileItem or FileUploadException.
 * <p>
 * This filter requires that at least the following JAR's (newer versions are allowed) in the
 * classpath, e.g. /WEB-INF/lib.
 * <ul>
 * <li>commons-fileupload-1.2.jar</li>
 * <li>commons-io-1.3.2.jar</li>
 * </ul>
 * <p>
 * This filter should be definied as follows in the web.xml:
 * <pre>
 * &lt;filter&gt;
 *     &lt;description&gt;
 *         Check for multipart HttpServletRequests and parse the multipart form data so that all
 *         regular form fields are available in the parameterMap of the HttpServletRequest and that
 *         all form file fields are available as attribute of the HttpServletRequest. The attribute
 *         value of a form file field can be an instance of FileItem or FileUploadException.
 *     &lt;/description&gt;
 *     &lt;filter-name&gt;multipartFilter&lt;/filter-name&gt;
 *     &lt;filter-class&gt;net.balusc.webapp.MultipartFilter&lt;/filter-class&gt;
 *     &lt;init-param&gt;
 *         &lt;description&gt;
 *             Sets the maximum file size of the uploaded file in bytes. Set to 0 to indicate an
 *             unlimited file size. The example value of 1048576 indicates a maximum file size of
 *             1MB. This parameter is not required and can be removed safely.
 *         &lt;/description&gt;
 *         &lt;param-name&gt;maxFileSize&lt;/param-name&gt;
 *         &lt;param-value&gt;1048576&lt;/param-value&gt;
 *     &lt;/init-param&gt;
 * &lt;/filter&gt;
 * &lt;filter-mapping&gt;
 *     &lt;filter-name&gt;multipartFilter&lt;/filter-name&gt;
 *     &lt;url-pattern&gt;/*&lt;/url-pattern&gt;
 * &lt;/filter-mapping&gt;
 * </pre>
 *
 * @author BalusC
 * @link http://balusc.blogspot.com/2007/11/multipartfilter.html
 */
public class MultipartFilter implements Filter {

    // Init ---------------------------------------------------------------------------------------

    private long maxFileSize;

    // Actions ------------------------------------------------------------------------------------

    /**
     * Configure the 'maxFileSize' parameter.
     * @throws ServletException If 'maxFileSize' parameter value is not numeric.
     * @see javax.servlet.Filter#init(javax.servlet.FilterConfig)
     */
    public void init(FilterConfig filterConfig) throws ServletException {
        // Configure maxFileSize.
        String maxFileSize = filterConfig.getInitParameter("maxFileSize");
        if (maxFileSize != null) {
            if (!maxFileSize.matches("^\\d+$")) {
                throw new ServletException("MultipartFilter 'maxFileSize' is not numeric.");
            }
            this.maxFileSize = Long.parseLong(maxFileSize);
        }
    }

    /**
     * Check the type request and if it is a HttpServletRequest, then parse the request.
     * @throws ServletException If parsing of the given HttpServletRequest fails.
     * @see javax.servlet.Filter#doFilter(
     *      javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)
     */
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws ServletException, IOException
    {
        // Check type request.
        if (request instanceof HttpServletRequest) {
            // Cast back to HttpServletRequest.
            HttpServletRequest httpRequest = (HttpServletRequest) request;

            // Parse HttpServletRequest.
            HttpServletRequest parsedRequest = parseRequest(httpRequest);

            // Continue with filter chain.
            chain.doFilter(parsedRequest, response);
        } else {
            // Not a HttpServletRequest.
            chain.doFilter(request, response);
        }
    }

    /**
     * @see javax.servlet.Filter#destroy()
     */
    public void destroy() {
        // I am a boring method.
    }

    // Helpers ------------------------------------------------------------------------------------

    /**
     * Parse the given HttpServletRequest. If the request is a multipart request, then all multipart
     * request items will be processed, else the request will be returned unchanged. During the
     * processing of all multipart request items, the name and value of each regular form field will
     * be added to the parameterMap of the HttpServletRequest. The name and File object of each form
     * file field will be added as attribute of the given HttpServletRequest. If a
     * FileUploadException has occurred when the file size has exceeded the maximum file size, then
     * the FileUploadException will be added as attribute value instead of the FileItem object.
     * @param request The HttpServletRequest to be checked and parsed as multipart request.
     * @return The parsed HttpServletRequest.
     * @throws ServletException If parsing of the given HttpServletRequest fails.
     */
    @SuppressWarnings("unchecked") // ServletFileUpload#parseRequest() does not return generic type.
    private HttpServletRequest parseRequest(HttpServletRequest request) throws ServletException {

        // Check if the request is actually a multipart/form-data request.
        if (!ServletFileUpload.isMultipartContent(request)) {
            // If not, then return the request unchanged.
            return request;
        }

        // Prepare the multipart request items.
        // I'd rather call the "FileItem" class "MultipartItem" instead or so. What a stupid name ;)
        List<FileItem> multipartItems = null;

        try {
            // Parse the multipart request items.
            multipartItems = new ServletFileUpload(new DiskFileItemFactory()).parseRequest(request);
            // Note: we could use ServletFileUpload#setFileSizeMax() here, but that would throw a
            // FileUploadException immediately without processing the other fields. So we're
            // checking the file size only if the items are already parsed. See processFileField().
        } catch (FileUploadException e) {
            throw new ServletException("Cannot parse multipart request: " + e.getMessage());
        }

        // Prepare the request parameter map.
        Map<String, String[]> parameterMap = new HashMap<String, String[]>();

        // Loop through multipart request items.
        for (FileItem multipartItem : multipartItems) {
            if (multipartItem.isFormField()) {
                // Process regular form field (input type="text|radio|checkbox|etc", select, etc).
                processFormField(multipartItem, parameterMap);
            } else {
                // Process form file field (input type="file").
                processFileField(multipartItem, request);
            }
        }

        // Wrap the request with the parameter map which we just created and return it.
        return wrapRequest(request, parameterMap);
    }

    /**
     * Process multipart request item as regular form field. The name and value of each regular
     * form field will be added to the given parameterMap.
     * @param formField The form field to be processed.
     * @param parameterMap The parameterMap to be used for the HttpServletRequest.
     */
    private void processFormField(FileItem formField, Map<String, String[]> parameterMap) {
        String name = formField.getFieldName();
        String value = formField.getString();
        String[] values = parameterMap.get(name);

        if (values == null) {
            // Not in parameter map yet, so add as new value.
            parameterMap.put(name, new String[] { value });
        } else {
            // Multiple field values, so add new value to existing array.
            int length = values.length;
            String[] newValues = new String[length + 1];
            System.arraycopy(values, 0, newValues, 0, length);
            newValues[length] = value;
            parameterMap.put(name, newValues);
        }
    }

    /**
     * Process multipart request item as file field. The name and FileItem object of each file field
     * will be added as attribute of the given HttpServletRequest. If a FileUploadException has
     * occurred when the file size has exceeded the maximum file size, then the FileUploadException
     * will be added as attribute value instead of the FileItem object.
     * @param fileField The file field to be processed.
     * @param request The involved HttpServletRequest.
     */
    private void processFileField(FileItem fileField, HttpServletRequest request) {
        if (fileField.getName().length() <= 0) {
            // No file uploaded.
            request.setAttribute(fileField.getFieldName(), null);
        } else if (maxFileSize > 0 && fileField.getSize() > maxFileSize) {
            // File size exceeds maximum file size.
            request.setAttribute(fileField.getFieldName(), new FileUploadException(
                "File size exceeds maximum file size of " + maxFileSize + " bytes."));
            // Immediately delete temporary file to free up memory and/or disk space.
            fileField.delete();
        } else {
            // File uploaded with good size.
            request.setAttribute(fileField.getFieldName(), fileField);
        }
    }

    // Utility (may be refactored to public utility class) ----------------------------------------

    /**
     * Wrap the given HttpServletRequest with the given parameterMap.
     * @param request The HttpServletRequest of which the given parameterMap have to be wrapped in.
     * @param parameterMap The parameterMap to be wrapped in the given HttpServletRequest.
     * @return The HttpServletRequest with the parameterMap wrapped in.
     */
    private static HttpServletRequest wrapRequest(
        HttpServletRequest request, final Map<String, String[]> parameterMap)
    {
        return new HttpServletRequestWrapper(request) {
            public Map<String, String[]> getParameterMap() {
                return parameterMap;
            }
            public String[] getParameterValues(String name) {
                return parameterMap.get(name);
            }
            public String getParameter(String name) {
                String[] params = getParameterValues(name);
                return params != null && params.length > 0 ? params[0] : null;
            }
            public Enumeration<String> getParameterNames() {
                return Collections.enumeration(parameterMap.keySet());
            }
        };
    }

}

Add and configure the filter as follows in the web.xml:

<filter>
    <description>
        Check for multipart HttpServletRequests and parse the multipart form data so that all
        regular form fields are available in the parameterMap of the HttpServletRequest and that
        all form file fields are available as attribute of the HttpServletRequest. The attribute
        value of a form file field can be an instance of FileItem or FileUploadException.
    </description>
    <filter-name>multipartFilter</filter-name>
    <filter-class>net.balusc.webapp.MultipartFilter</filter-class>
    <init-param>
        <description>
            Sets the maximum file size of the uploaded file in bytes. Set to 0 to indicate an
            unlimited file size. The example value of 1048576 indicates a maximum file size of
            1MB. This parameter is not required and can be removed safely.
        </description>
        <param-name>maxFileSize</param-name>
        <param-value>1048576</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>multipartFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

That's all, folks!

Back to top

Basic use example

Here is a basic use example of a servlet, a form and JSP file which demonstrates the working of the MultipartFilter. Thanks to the MultipartFilter you can just use HttpServletRequest#getParameter() and #getParameterValues() for regular form fields. The uploaded file is available by HttpServletRequest#getAttribute(). If it is an instance of FileItem, then the upload was succesful, else if it is an instance of FileUploadException, then the upload was failed. The only cause can be that the file size exceeded the configured maximum file size.

package mypackage;

import java.io.File;
import java.io.IOException;
import java.util.Collection;
import java.util.Map;

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

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileUploadException;

import net.balusc.util.FileUtil;
import net.balusc.util.StringUtil;

public class MyServlet extends HttpServlet {

    // Init ---------------------------------------------------------------------------------------

    private File uploadFilePath;

    // Actions ------------------------------------------------------------------------------------

    public void init() throws ServletException {
        // Configure uploadFilePath.
        String uploadFilePathParam = getServletConfig().getInitParameter("uploadFilePath");
        if (uploadFilePathParam == null) {
            throw new ServletException("MyServlet 'uploadFilePath' is not configured.");
        }
        uploadFilePath = new File(uploadFilePathParam);
        if (!uploadFilePath.exists()) {
            throw new ServletException("MyServlet 'uploadFilePath' does not exist.");
        }
        if (!uploadFilePath.isDirectory()) {
            throw new ServletException("MyServlet 'uploadFilePath' is not a directory.");
        }
        if (!uploadFilePath.canWrite()) {
            throw new ServletException("MyServlet 'uploadFilePath' is not writeable.");
        }
    }

    protected void doGet(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException
    {
        // Do nothing, just show the form.
        forward(request, response);
    }

    protected void doPost(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException
    {
        // Prepare bean.
        MyForm myForm = new MyForm();

        // Process request.
        process(request, myForm);

        // Store bean in request.
        request.setAttribute("myForm", myForm);

        // Postback.
        forward(request, response);
    }

    // Helpers ------------------------------------------------------------------------------------

    private void process(HttpServletRequest request, MyForm myForm) {
        // Validate text.
        String text = request.getParameter("text");
        if (isEmpty(text)) {
            // No text entered.
            myForm.setError("text", "Please enter some text.");
        }

        // Validate file.
        Object fileObject = request.getAttribute("file");
        if (fileObject == null) {
            // No file uploaded.
            myForm.setError("file", "Please select file to upload.");
        } else if (fileObject instanceof FileUploadException) {
            // File upload is failed.
            FileUploadException fileUploadException = (FileUploadException) fileObject;
            myForm.setError("file", fileUploadException.getMessage());
        }

        // Validate checkboxes.
        String[] check = request.getParameterValues("check");
        if (isEmpty(check)) {
            // No checkboxes checked.
            myForm.setError("check", "Please check one or more checkboxes.");
        }

        // If there are no errors, proceed with writing file.
        if (!myForm.hasErrors()) {
            FileItem fileItem = (FileItem) fileObject;

            // Get file name from uploaded file and trim path from it.
            // Some browsers (e.g. IE, Opera) also sends the path, which is completely irrelevant.
            String fileName = FileUtil.trimFilePath(fileItem.getName());

            try {
                // Prepare unique local file based on file name of uploaded file.
                File file = FileUtil.uniqueFile(uploadFilePath, fileName);

                // Write uploaded file to local file.
                fileItem.write(file);

                // Set the file in form so that it can be provided for download.
                myForm.setFile(file);
            } catch (Exception e) {
                // Can be thrown by uniqueFile() and FileItem#write().
                myForm.setError("file", e.getMessage());
                e.printStackTrace();
            }
        }

        // If there are no errors after writing file, proceed with showing messages.
        if (!myForm.hasErrors()) {
            myForm.setMessage("text", "You have entered: " + text + ".");
            myForm.setMessage("file", "File succesfully uploaded.");
            myForm.setMessage("check", "You have checked: " + StringUtil.join(check, ",") + ".");
        }
    }

    private void forward(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException
    {
        request.getRequestDispatcher("myForm.jsp").forward(request, response);
    }

    // Utilities (should be refactored to public utility classes) ---------------------------------

    /**
     * Check if the given object is empty. Returns true if the object is null, or if it is an
     * instance of String and its trimmed length is zero, or if it is an instance of an ordinary
     * array and its length is zero, or if it is an instance of Collection and its size is zero,
     * or if it is an instance of Map and its size is zero, or if its String representation is
     * null or the trimmed length of its String representation is zero.
     * @param value The object to be determined on emptiness.
     * @return True if the given object value is empty.
     */
    public static boolean isEmpty(Object value) {
        if (value == null) {
            return true;
        } else if (value instanceof String) {
            return ((String) value).trim().length() == 0;
        } else if (value instanceof Object[]) {
            return ((Object[]) value).length == 0;
        } else if (value instanceof Collection<?>) {
            return ((Collection<?>) value).size() == 0;
        } else if (value instanceof Map<?, ?>) {
            return ((Map<?, ?>) value).size() == 0;
        } else {
            return value.toString() == null || value.toString().trim().length() == 0;
        }
    }

}

Note: you can find the required net.balusc.util classes here: FileUtil and StringUtil.

Add and configure the servlet as follows in the web.xml:

<servlet>
    <servlet-name>myServlet</servlet-name>
    <servlet-class>mypackage.MyServlet</servlet-class>
    <init-param>
        <description>
            Set the file path where uploaded files should be stored in. This parameter is
            required.
        </description>
        <param-name>uploadFilePath</param-name>
        <param-value>c:/upload</param-value>
    </init-param>
</servlet>
<servlet-mapping>
    <servlet-name>myServlet</servlet-name>
    <url-pattern>/myServlet</url-pattern>
</servlet-mapping>

The form bean:

package mypackage;

import java.io.File;
import java.util.HashMap;
import java.util.Map;

public class MyForm {

    // Init ---------------------------------------------------------------------------------------

    private String text;
    private File file;
    private String[] check;
    private Map<String, Boolean> checked = new HashMap<String, Boolean>();
    private Map<String, String> errors = new HashMap<String, String>();
    private Map<String, String> messages = new HashMap<String, String>();

    // Getters ------------------------------------------------------------------------------------

    public String getText() {
        return text;
    }

    public File getFile() {
        return file;
    }

    public String[] getCheck() {
        return check;
    }

    // Setters ------------------------------------------------------------------------------------

    public void setText(String text) {
        this.text = text;
    }

    public void setFile(File file) {
        this.file = file;
    }

    public void setCheck(String[] check) {
        checked = new HashMap<String, Boolean>();
        for (String value : check) {
            checked.put(value, Boolean.TRUE);
        }
        this.check = check;
    }

    // Helpers ------------------------------------------------------------------------------------

    public Map<String, Boolean> getChecked() {
        return checked;
    }

    public Map<String, String> getErrors() {
        return errors;
    }

    public Map<String, String> getMessages() {
        return messages;
    }

    public void setError(String fieldName, String message) {
        errors.put(fieldName, message);
    }