Archive

Archive for August, 2008

Pretty URLs in JSF 1.2

August 28, 2008 Leave a comment

In one of my recent projects i had to build a JSF-based frontend for a content management system (CMS). It was required that each document of the CMS was accessible via an URL without query parameter. Instead of
http://acme.com/doc.jsf?title=company+news&lang=en&year=2008&topic=general
the URL had to be something like 
http://acme.com/en/2008/general/company+news.

JSF lacks support for bookmarkable or pretty URLs (at least until version 2.0) and so we had to either choose one of the existing “make-jsf-bookmarkable”-libraries or implement the features ourselves.

A first search led us to the PrettyUrlPhaseListener, a jsf PhaseListener that is part of the sandbox of Sun’s Glassfish project. ¬†
It was a good starting point, but we needed some additional features:

  • configure urls with wildcard pattern
  • initializing bean properties based on matched url

What we came up with is a modified and enhanced version of the PhaseListener.

Let’s start with the configuration of the PhaseListener:

web.xml:


	<!--  context param for PrettyUrlPhaseListener -->
	<context-param>
		<param-name>com.sun.faces.sandbox.urlPatterns</param-name>
		<param-value>
			<![CDATA[
			<urlPatterns>
				<urlPattern pattern="(/(en|de)/.+?)(?:\.html)?" viewId="pages/content.html">
					<match expression="#{contentBean.namedPath}"/>
					<match expression="#{localeBean.language}"/>
				</urlPattern>
				<urlPattern pattern="/(?:en|INT/int)/?" viewId="pages/home.html">
					<match type="fixed" expression="#{localeBean.language}" value="en"/>
				</urlPattern>
				<urlPattern pattern="/(?:de|DE/de)/?" viewId="pages/home.html">
					<match type="fixed" expression="#{localeBean.language}" value="de"/>
				</urlPattern>
				<urlPattern pattern="/send2friend/(.+)\.html" viewId="pages/forms/send2friend.html">
					<match expression="#{send2FriendBean.documentId}"/>
				</urlPattern>
				<urlPattern pattern="/banner/(.+?)/(.+?)/(.+?)/(.+?)\.html" viewId="pages/banner.html">
					<match expression="#{requestScope.position}"/>
					<match expression="#{requestScope.key}"/>
					<match expression="#{requestScope.width}"/>
					<match expression="#{requestScope.height}"/>
				</urlPattern>
			</urlPatterns>
			]]>
		</param-value>
	</context-param>

The value of this context-param is a list of URL patterns. When the PhaseListener processes each request, it uses a regular expression created from each URL pattern to determine if the current request URL is one which it should process. If it is, the value from the request URL that corresponds to each EL expression in the pattern is extracted, URL-decoded, then set on the ValueBinding created from the EL. Once all the values have been processed, JSF is instructed to display the actual template file, specified by the attribute viewId in the URL pattern.

In some cases it might be necessary to initialize a bean property with a fixed value that is not derived from the request URL. This is what the attribute fixed is for.

The last urlPattern in the sample configuration shows that you can use the PhaseListener to initalize JSF-built-in parameters.

Like with the original PrettyURLPhaseListener the servlet mappings must be added to web.xml.

	<servlet-mapping>
		<servlet-name>Faces Servlet</servlet-name>
		<url-pattern>/en/*</url-pattern>
	</servlet-mapping>
	<servlet-mapping>
		<servlet-name>Faces Servlet</servlet-name>
		<url-pattern>/de/*</url-pattern>
	</servlet-mapping>
	<servlet-mapping>
		<servlet-name>Faces Servlet</servlet-name>
		<url-pattern>/INT/*</url-pattern>
	</servlet-mapping>
	<servlet-mapping>
		<servlet-name>Faces Servlet</servlet-name>
		<url-pattern>/DE/*</url-pattern>
	</servlet-mapping>

If the user requests http://acme.com/de/2008/general/company+news the phase listener calls localeBean.setLanguage('de') and contentBean.setNamedPath('/2008/general/company news') before displaying the view template pages/content.html.

Here are the required java files:


/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright 1997-2007 Sun Microsystems, Inc. All rights reserved.
 *
 * The contents of this file are subject to the terms of either the GNU
 * General Public License Version 2 only ("GPL") or the Common Development
 * and Distribution License("CDDL") (collectively, the "License").  You
 * may not use this file except in compliance with the License. You can obtain
 * a copy of the License at https://glassfish.dev.java.net/public/CDDL+GPL.html
 * or glassfish/bootstrap/legal/LICENSE.txt.  See the License for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing the software, include this License Header Notice in each
 * file and include the License file at glassfish/bootstrap/legal/LICENSE.txt.
 * Sun designates this particular file as subject to the "Classpath" exception
 * as provided by Sun in the GPL Version 2 section of the License file that
 * accompanied this code.  If applicable, add the following below the License
 * Header, with the fields enclosed by brackets [] replaced by your own
 * identifying information: "Portions Copyrighted [year]
 * [name of copyright owner]"
 *
 * Contributor(s):
 *
 * If you wish your version of this file to be governed by only the CDDL or
 * only the GPL Version 2, indicate your decision by adding "[Contributor]
 * elects to include this software in this distribution under the [CDDL or GPL
 * Version 2] license."  If you don't indicate a single choice of license, a
 * recipient has the option to distribute your version of this file under
 * either the CDDL, the GPL Version 2 or to extend the choice of license to
 * its licensees as provided above.  However, if you add GPL Version 2 code
 * and therefore, elected the GPL Version 2 license, then the option applies
 * only if the new code is made subject to such option by the copyright
 * holder.
 */
package de.flower.jsf.phaselistener;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.el.ExpressionFactory;
import javax.el.ValueExpression;
import javax.faces.FacesException;
import javax.faces.FactoryFinder;
import javax.faces.context.FacesContext;
import javax.faces.event.PhaseEvent;
import javax.faces.event.PhaseId;
import javax.faces.event.PhaseListener;
import javax.faces.lifecycle.Lifecycle;
import javax.faces.lifecycle.LifecycleFactory;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

import org.apache.commons.jxpath.JXPathContext;
import org.apache.log4j.Logger;
import org.jdom.Document;
import org.jdom.Element;
import org.jdom.JDOMException;

/**
 * The Class PrettyUrlPhaseListener.
 *
 * PL that allows usage of 'pretty urls'. Maps pretty urls to new view ids and
 * initializes backing beans with information that is contained in the prettyurl
 * (eg. language, namedpath, etc).
 *
 * @author Jason Lee
 * @author Oliver Blume - rewrote configuration and pattern matching.
 */
public class PrettyUrlPhaseListener implements PhaseListener {

	/** The Constant URL_PATTERNS_INIT_PARAM. */
	public static final String URL_PATTERNS_INIT_PARAM = "com.sun.faces.sandbox.urlPatterns";

	/** The url patterns. */
	private List<UrlPattern> urlPatterns;

	public PhaseId getPhaseId() {
		return PhaseId.RESTORE_VIEW;
	}

	public void beforePhase(PhaseEvent event) {
		if (event.getPhaseId() == PhaseId.RESTORE_VIEW) {
			FacesContext context = FacesContext.getCurrentInstance();
			ExpressionFactory ef = context.getApplication()
					.getExpressionFactory();

			urlPatterns = loadUrlPatterns(context);

			HttpServletRequest request = (HttpServletRequest) context
					.getExternalContext().getRequest();
			String uri = request.getRequestURI();
			String contextPath = request.getContextPath();
			uri = uri.substring(contextPath.length());
			if (uri != null) {

				UrlMatcher urlMatcher = new UrlMatcher();
				urlMatcher.setUrlPatterns(urlPatterns);
				UrlPattern result = urlMatcher.matchUrl(uri);
				if (result != null) {
					// Set properties
					for (Map.Entry<String, String> injection : result
							.getAllExpressions().entrySet()) {
						try {
							String elExpression = injection.getKey();
							String uriValue = injection.getValue();

							ValueExpression ve = ef.createValueExpression(
									context.getELContext(), elExpression,
									Object.class);
							if (ve != null) {
								ve.setValue(context.getELContext(),
								// ef.coerceToType(URLDecoder.decode(uriValue,
								// "UTF-8"),
										ef.coerceToType(uriValue,
												ve.getType(context
														.getELContext())));
							}
						} catch (RuntimeException ee) {
							throw new FacesException(ee);
						}
					}

					PrettyUrlRequestWrapper wrapper = new PrettyUrlRequestWrapper(
							request);
					wrapper.setViewId(result.getViewId());
					context.getExternalContext().setRequest(wrapper);
				}
			}
		}
	}

	/**
	 * Load url patterns.
	 *
	 * @param context
	 *            the context
	 *
	 * @return the list< url pattern>
	 */
	protected List<UrlPattern> loadUrlPatterns(FacesContext context) {
		if (urlPatterns == null) {
			String patternInitParam = context.getExternalContext()
					.getInitParameter(URL_PATTERNS_INIT_PARAM);
			try {
				urlPatterns = loadConfiguration(new ByteArrayInputStream(
						patternInitParam.getBytes()));
			} catch (Exception e) {
				// just wrap it into a RuntimeException so it doesn't pollute
				// the throws clause.
				throw new RuntimeException(e);
			}
			// If no URL patterns have been registered, unregister the PL
			if (urlPatterns.size() == 0) {
				LifecycleFactory factory = (LifecycleFactory) FactoryFinder
						.getFactory(FactoryFinder.LIFECYCLE_FACTORY);
				// remove ourselves from the list of listeners maintained by
				// the lifecycle instances
				for (Iterator<String> i = factory.getLifecycleIds(); i
						.hasNext();) {
					Lifecycle lifecycle = factory.getLifecycle(i.next());
					lifecycle.removePhaseListener(this);
				}
			}
		}
		return urlPatterns;
	}

	/**
	 * Load configuration.
	 *
	 * @param in
	 *            the in
	 *
	 * @return the list< url pattern>
	 *
	 * @throws JDOMException
	 *             the JDOM exception
	 * @throws IOException
	 *             Signals that an I/O exception has occurred.
	 */
	public static List<UrlPattern> loadConfiguration(InputStream in)
			throws JDOMException, IOException {
		List<UrlPattern> result = new ArrayList<UrlPattern>();

		SAXBuilder builder = new SAXBuilder();
		Document document = builder.build(in);

		JXPathContext context = JXPathContext.newContext(document);
		List<Element> nodes = context.selectNodes("/urlPatterns/urlPattern");
		for (Element node : nodes) {
			context = JXPathContext.newContext(node);
			UrlPattern urlPattern = new UrlPattern();
			urlPattern.setPattern((String) context.getValue("@pattern"));
			urlPattern.setViewId((String) context.getValue("@viewId"));
			List<Element> matches = context.selectNodes("/match");
			for (Element match : matches) {
				String type = match.getAttributeValue("type");
				String expression = match.getAttributeValue("expression");
				String value = match.getAttributeValue("value");
				if ("fixed".equals(type)) {
					urlPattern.getFixedExpressions().put(expression, value);
				} else {
					urlPattern.getExpressions().put(expression, null);
				}
			}
			result.add(urlPattern);
		}
		return result;

	}

	/**
	 * Sets the url patterns.
	 *
	 * @param urlPatterns
	 *            the new url patterns
	 */
	public void setUrlPatterns(List<UrlPattern> urlPatterns) {
		this.urlPatterns = urlPatterns;
	}

	public void afterPhase(PhaseEvent event) {
		// Nothing happens here
	}
}

class PrettyUrlRequestWrapper extends HttpServletRequestWrapper {
	private String viewId;

	@Override
	public String getPathInfo() {
		return null;
	}

	@Override
	public String getServletPath() {
		return viewId;
	}

	public PrettyUrlRequestWrapper(HttpServletRequest request) {
		super(request);
	}

	public void setViewId(String viewId) {
		this.viewId = "/" + viewId;
	}
}

PrettyURLPhaseListener.java

package de.flower.jsf.phaselistener;

import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.log4j.Logger;

/**
 * The Class UrlMatcher. Part of the {@link PrettyUrlPhaseListener}.
 *
 * @author Oliver Blume
 */
public class UrlMatcher {

	/** The log. */
	private final Logger log = Logger.getLogger(getClass());

	/** The url patterns. */
	private List<UrlPattern> urlPatterns;

	/**
	 * Match url.
	 *
	 * @param url
	 *            the url
	 *
	 * @return the url pattern
	 */
	public UrlPattern matchUrl(String url) {
		UrlPattern result;
		// iterate over the list of url patterns
		// first match wins
		for (UrlPattern urlPattern : urlPatterns) {
			Matcher m = Pattern.compile(urlPattern.getPattern()).matcher(url);
			if (m.matches()) {
				int numGroups = m.groupCount();
				if (numGroups == urlPattern.getExpressions().size()) {
					result = urlPattern.deepCopy();
					int i = 1;
					for (String expression : urlPattern.getExpressions()
							.keySet()) {
						result.getExpressions().put(expression, m.group(i));
						i++;
					}
					return result;
				} else {
					// url matches but does not match enough groups
					log.debug("urlPattern [" + urlPattern.getPattern()
							+ "] matches url [" + url
							+ "], but number of matches [" + numGroups
							+ "] does not equal number of expressions ["
							+ urlPattern.getExpressions().size() + "].");
				}
			}
		}
		return null;
	}

	/**
	 * Gets the url patterns.
	 *
	 * @return the urlPatterns
	 */
	public List<UrlPattern> getUrlPatterns() {
		return urlPatterns;
	}

	/**
	 * Sets the url patterns.
	 *
	 * @param urlPatterns
	 *            the urlPatterns to set
	 */
	public void setUrlPatterns(List<UrlPattern> urlPatterns) {
		this.urlPatterns = urlPatterns;
	}

}

UrlMatcher.java

package de.flower.jsf.phaselistener;

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * The Class UrlPattern. Part of the {@link PrettyUrlPhaseListener}.
 *
 * @author Oliver Blume
 */
public class UrlPattern {

	/** The pattern. */
	private String pattern;

	/** The view id. */
	private String viewId;

	/**
	 * The expression map. If pattern matches the map is filled with the
	 * capturing groups.
	 */
	private Map<String, String> expressions = new LinkedHashMap<String, String>();

	/** The fixed expressions. Used to pass static values to EL expressions. */
	private Map<String, String> fixedExpressions = new HashMap<String, String>();

	/**
	 * Gets the pattern.
	 *
	 * @return the pattern
	 */
	public String getPattern() {
		return pattern;
	}

	/**
	 * Sets the pattern.
	 *
	 * @param pattern
	 *            the pattern to set
	 */
	public void setPattern(String pattern) {
		this.pattern = pattern;
	}

	/**
	 * Gets the view id.
	 *
	 * @return the viewId
	 */
	public String getViewId() {
		return viewId;
	}

	/**
	 * Sets the view id.
	 *
	 * @param viewId
	 *            the viewId to set
	 */
	public void setViewId(String viewId) {
		this.viewId = viewId;
	}

	/**
	 * Gets the expressions.
	 *
	 * @return the expressions
	 */
	public Map<String, String> getExpressions() {
		return expressions;
	}

	/**
	 * Sets the expressions.
	 *
	 * @param expressions
	 *            the expressions to set
	 */
	public void setExpressions(Map<String, String> expressions) {
		this.expressions = expressions;
	}

	/**
	 * @return the fixedExpressions
	 */
	public Map<String, String> getFixedExpressions() {
		return fixedExpressions;
	}

	/**
	 * @param fixedExpressions
	 *            the fixedExpressions to set
	 */
	public void setFixedExpressions(Map<String, String> fixedExpressions) {
		this.fixedExpressions = fixedExpressions;
	}

	public Map<String, String> getAllExpressions() {
		Map<String, String> result = new HashMap<String, String>(
				this.expressions);
		result.putAll(this.fixedExpressions);
		return result;
	}

	/**
	 * Deep copy.
	 *
	 * @return the url pattern
	 */
	public UrlPattern deepCopy() {
		UrlPattern copy = new UrlPattern();
		copy.pattern = this.pattern;
		copy.viewId = this.viewId;
		copy.expressions = new HashMap<String, String>(this.expressions);
		copy.fixedExpressions = new HashMap<String, String>(
				this.fixedExpressions);
		return copy;
	}

}

UrlPattern.java

The xml configuration is parsed with JDOM and JXPath from the Apache Commons library. If you are bound to use other xml libraries in your project you could easily rewrite that part of the code.

Advertisements
Categories: Java Tags: , ,