/*
* Copyright 2006 - 2007 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springmodules.xt.ajax;
import java.io.IOException;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import net.sf.json.JSONObject;
import org.apache.commons.collections.MultiMap;
import org.apache.commons.collections.map.MultiValueMap;
import org.apache.log4j.Logger;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.validation.Errors;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import org.springframework.web.util.UrlPathHelper;
import org.springframework.web.util.WebUtils;
import org.springmodules.xt.ajax.support.IllegalViewException;
import org.springmodules.xt.ajax.support.NoMatchingHandlerException;
import org.springmodules.xt.ajax.support.UnsupportedEventException;
import org.springmodules.xt.ajax.action.RedirectAction;
import org.springmodules.xt.ajax.util.AjaxResponseSender;
/**
* <p>Spring web interceptor which intercepts http requests and handles ajax requests.<br>
* Ajax requests are identified by a particular request parameter, by default named "ajax-request": it can assume two different values, depending on the type of ajax request:
* an "action request", causing no form submission, and a "submit request", causing form submission.</p>
*
* <p>This interceptor delegates ajax requests handling to {@link AjaxHandler}s configured via handler mappings
* (see {@link #setHandlerMappings(Properties)}).</p>
*
* <p>Configured mappings are a {@link java.util.Properties} file / object where each entry associates an ANT based URL path with a comma separated list of
* {@link AjaxHandler}s configured in the Spring application context; when associating the same URL pattern with multiple handlers,
* all handlers will be merged.</p>
*
* <p>When the interceptor receives an ajax request, it looks its mappings for an appropriate set of handlers: then, each handler will be evaluated following
* the longest path match order, that is, an handler configured in the path "/test" will be evaluated prior to an handler configured in the path "/*".</p>
*
* <p>The first handler supporting the ajax event associated with the request will be executed.<br>
* If the same URL is associated with more than one handler, the one supporting the current event will be executed.</p>
*
* <p>Note that if more handlers support the same event, the one configured for matching the longest path will be executed (in the example above,
* the one configured for the path "/test"): this is useful for overriding event handlers.</p>
*
* @author Sergio Bossa
*/
public class AjaxInterceptor extends HandlerInterceptorAdapter implements ApplicationContextAware {
public static final String AJAX_ACTION_REQUEST = "ajax-action";
public static final String AJAX_SUBMIT_REQUEST = "ajax-submit";
public static final String AJAX_REDIRECT_PREFIX = "ajax-redirect:";
public static final String STANDARD_REDIRECT_PREFIX = "redirect:";
private static final String MODEL_KEY = AjaxInterceptor.class.getName() + ".MODEL_KEY";
private static final Logger logger = Logger.getLogger(AjaxInterceptor.class);
private UrlPathHelper urlPathHelper = new UrlPathHelper();
private PathMatcher pathMatcher = new AntPathMatcher();
private String ajaxParameter = "ajax-request";
private String eventParameter = "event-id";
private String elementParameter = "source-element";
private String elementIdParameter = "source-element-id";
private String jsonParamsParameter = "json-params";
private FormDataAccessor formDataAccessor = new MVCFormDataAccessor();
private MultiMap handlerMappings = new MultiValueMap();
private ApplicationContext applicationContext;
/**
* Pre-handle the http request and if this is an ajax request firing an action, looks for a mapped ajax handler, executes it and
* returns an ajax response.<br>
* Important: if the matching mapped handler returns a <b>null</b> or empty ajax response, the interceptor <b>does not proceed</b> with the execution chain.
*
* @throws UnsupportedEventException If the event associated with this ajax request is not supported by any
* mapped handler.
* @throws EventHandlingException If an error occurred during event handling.
* @throws NoMatchingHandlerException If no mapped handler matching the URL can be found.
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (WebUtils.isIncludeRequest(request)) {
return true;
}
try {
String requestType = request.getParameter(this.ajaxParameter);
if (requestType != null && requestType.equals(AJAX_ACTION_REQUEST)) {
String eventId = request.getParameter(this.eventParameter);
if (eventId == null) {
throw new IllegalStateException("Event id cannot be null.");
}
logger.info(new StringBuilder("Pre-handling ajax request for event: ").append(eventId));
List<AjaxHandler> handlers = this.lookupHandlers(request);
if (handlers.isEmpty()) {
throw new NoMatchingHandlerException("Cannot find an handler matching the request: " +
this.urlPathHelper.getLookupPathForRequest(request));
} else {
AjaxActionEvent event = new AjaxActionEventImpl(eventId, request);
AjaxResponse ajaxResponse = null;
boolean supported = false;
for (AjaxHandler ajaxHandler : handlers) {
if (ajaxHandler.supports(event)) {
// Set event properties:
this.initEvent(event, request);
Map model = this.getModel(request.getSession());
if (model != null) {
Object commandObject = this.formDataAccessor.getCommandObject(request, response, handler, model);
event.setCommandObject(commandObject);
}
// Handle event:
ajaxResponse = ajaxHandler.handle(event);
supported = true;
break;
}
}
if (!supported) {
throw new UnsupportedEventException("Cannot handling the given event with id: " + eventId);
} else {
if (ajaxResponse != null && !ajaxResponse.isEmpty()) {
logger.info("Sending Ajax response after Ajax action.");
AjaxResponseSender.sendResponse(response, ajaxResponse);
} else {
AjaxResponseSender.sendResponse(response, new AjaxResponseImpl());
}
return false;
}
}
} else {
return true;
}
} catch(Exception ex) {
logger.error(ex.getMessage(), ex);
throw ex;
}
}
/**
* Post-handle the http request and if it was an ajax request firing a submit, looks for a mapped ajax handler, executes it and
* returns an ajax response.<br>
* If the matching mapped handler returns a null or empty ajax response, the interceptor looks for a view configured with
* the {@link #AJAX_REDIRECT_PREFIX} and make a redirect to it; if the configured view has no
* {@link #AJAX_REDIRECT_PREFIX}, an exception is thrown.
*
* @throws UnsupportedEventException If the event associated with this ajax request is not supported by any
* mapped handler.
* @throws EventHandlingException If an error occurred during event handling.
* @throws NoMatchingHandlerException If no mapped handler matching the URL can be found.
* @throws IllegalViewException If the view to which redirect to doesn't contain the {@link #AJAX_REDIRECT_PREFIX}.
*/
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)
throws Exception {
if (WebUtils.isIncludeRequest(request)) {
return;
}
try {
// If modelAndView object is null, it means that the controller handled the request by itself ...
// See : http://static.springframework.org/spring/docs/2.0.x/api/org/springframework/web/servlet/mvc/Controller.html#handleRequest(javax.servlet.http.HttpServletRequest,%20javax.servlet.http.HttpServletResponse)
if (modelAndView == null) {
logger.info("Null ModelAndView object, proceeding without Ajax processing ...");
return;
}
//
// Store the model map:
this.storeModel(request.getSession(), modelAndView.getModel());
//
// Continue processing:
//
String requestType = request.getParameter(this.ajaxParameter);
if (requestType != null && requestType.equals(AJAX_SUBMIT_REQUEST)) {
String eventId = request.getParameter(this.eventParameter);
if (eventId == null) {
throw new IllegalStateException("Event id cannot be null.");
}
logger.info(new StringBuilder("Post-handling ajax request for event: ").append(eventId));
List<AjaxHandler> handlers = this.lookupHandlers(request);
if (handlers.isEmpty()) {
throw new NoMatchingHandlerException("Cannot find an handler matching the request: " +
this.urlPathHelper.getLookupPathForRequest(request));
} else {
AjaxSubmitEvent event = new AjaxSubmitEventImpl(eventId, request);
AjaxResponse ajaxResponse = null;
boolean supported = false;
for (AjaxHandler ajaxHandler : handlers) {
if (ajaxHandler.supports(event)) {
// Set event properties:
this.initEvent(event, request);
Map model = this.getModel(request.getSession());
if (model != null) {
Object commandObject = this.formDataAccessor.getCommandObject(request, response, handler, model);
Errors errors = this.formDataAccessor.getValidationErrors(request, response, handler, model);
event.setCommandObject(commandObject);
event.setValidationErrors(errors);
event.setModel(model);
}
// Handle event:
ajaxResponse = ajaxHandler.handle(event);
supported = true;
break;
}
}
if (!supported) {
throw new UnsupportedEventException("Cannot handling the given event with id: " + eventId);
} else {
if (ajaxResponse != null && ! ajaxResponse.isEmpty()) {
// Need to clear the ModelAndView because we are handling the response by ourselves:
modelAndView.clear();
AjaxResponseSender.sendResponse(response, ajaxResponse);
} else {
// No response, so try an Ajax redirect:
String view = modelAndView.getViewName();
if (view != null && view.startsWith(AJAX_REDIRECT_PREFIX)) {
view = view.substring(AJAX_REDIRECT_PREFIX.length());
this.redirectToView(view, request, response, modelAndView);
} else if (view != null && view.startsWith(STANDARD_REDIRECT_PREFIX)) {
view = view.substring(STANDARD_REDIRECT_PREFIX.length());
this.redirectToView(view, request, response, modelAndView);
} else {
throw new IllegalViewException("No Ajax redirect prefix: " + AJAX_REDIRECT_PREFIX + " found for view: " + view);
}
}
}
}
}
} catch(Exception ex) {
logger.error(ex.getMessage(), ex);
throw ex;
}
}
/**
* Return true if the given request is an Ajax one, false otherwise.
*/
public boolean isAjaxRequest(HttpServletRequest request) {
String requestType = request.getParameter(this.ajaxParameter);
if (requestType != null && (requestType.equals(AJAX_ACTION_REQUEST) || requestType.equals(AJAX_SUBMIT_REQUEST))) {
return true;
} else {
return false;
}
}
/**
* Set the {@link FormDataAccessor} to use for getting form data to put in {@link AjaxEvent} objects.<br>
* By default it uses a {@link MVCFormDataAccessor}, in order to access form data in Spring MVC environments.<br>
* Set another accessor implementation for accessing form data in other environments, like Spring Web Flow.
*
* @param accessor The {@link FormDataAccessor}.
*/
public void setFormDataAccessor(FormDataAccessor accessor) {
this.formDataAccessor = accessor;
}
/**
* Set mappings configured in the given {@link java.util.Properties} object.<br>
* Each mapping associates an ANT based URL path with a comma separated list of {@link AjaxHandler}s configured in the Spring Application Context.<br>
* Mappings are ordered in a sorted map, following the longest path order (from the longest path to the shorter).<br>
* Please note that multiple mappings to the same URL are supported thanks to a {@link org.apache.commons.collections.map.MultiValueMap}.
*
* @param mappings A {@link java.util.Properties} containing handler mappings.
*/
public void setHandlerMappings(Properties mappings) {
this.handlerMappings = MultiValueMap.decorate(new TreeMap<String, String>(new Comparator() {
public int compare(Object o1, Object o2) {
if (!(o1 instanceof String) || !(o2 instanceof String)) {
throw new ClassCastException("You have to map an URL to a comma separated list of handler names.");
}
if (o1.equals(o2)) {
return 0;
} else if (o1.toString().length() > o2.toString().length()) {
return -1;
} else {
return 1;
}
}
}));
for (Map.Entry entry : mappings.entrySet()) {
String[] handlers = ((String) entry.getValue()).split(",");
for (String handler : handlers) {
String url = (String) entry.getKey();
if (! url.startsWith("/")) {
url = "/" + url;
}
this.handlerMappings.put(url.trim(), handler.trim());
}
}
}
public void setAjaxParameter(String ajaxParameter) {
this.ajaxParameter = ajaxParameter;
}
public void setElementParameter(String elementParameter) {
this.elementParameter = elementParameter;
}
public void setElementIdParameter(String elementIdParameter) {
this.elementIdParameter = elementIdParameter;
}
public void setEventParameter(String eventParameter) {
this.eventParameter = eventParameter;
}
public void setJsonParamsParameter(String jsonParamsParameter) {
this.jsonParamsParameter = jsonParamsParameter;
}
public String getAjaxParameter() {
return this.ajaxParameter;
}
public String getElementParameter() {
return this.elementParameter;
}
public String getElementIdParameter() {
return this.elementIdParameter;
}
public String getEventParameter() {
return this.eventParameter;
}
public String getJsonParamsParameter() {
return this.jsonParamsParameter;
}
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
this.applicationContext = applicationContext;
}
/*** Protected stuff ***/
/**
* Look up ajax handlers associated with the URL path of the given request.
* <p>Supports direct matches, e.g. "/test" matches "/test",
* and various Ant-style pattern matches, e.g. "/t*" matches
* both "/test" and "/team".</p>
* <p>Remember that multiple matches are merged: e.g., if both "/test" and "/t*" match,
* mappings will be merged and evaluated following longest path order.</p>
* <p>Moreover, remember that multiple handlers can be mapped to the same URL.</p>
*
* @param request The current http request.
* @return A {@link java.util.List} of {@link AjaxHandler}s associated with the URL of the given request; if no matching is found,
* an empty list is returned.
*/
protected List<AjaxHandler> lookupHandlers(HttpServletRequest request) {
String urlPath = this.urlPathHelper.getLookupPathForRequest(request);
List<AjaxHandler> handlers = new LinkedList<AjaxHandler>();
for (Map.Entry entry : (Set<Map.Entry>) this.handlerMappings.entrySet()) {
String configuredPath = (String) entry.getKey();
if (this.pathMatcher.match(configuredPath, urlPath)) {
Collection handlerNames = (Collection) entry.getValue();
for (Object handlerName : handlerNames) {
AjaxHandler current = (AjaxHandler) this.applicationContext.getBean((String) handlerName);
if (current != null) {
handlers.add(current);
} else {
logger.warn(new StringBuilder("Non-existent handler ").append(handlerName).append(" mapped at ").append(configuredPath));
}
}
}
}
return handlers;
}
/**
* Store the model map for later access and usage.
*/
protected void storeModel(HttpSession session, Map model) {
session.setAttribute(MODEL_KEY, model);
}
/**
* Get the model map.
*/
protected Map getModel(HttpSession session) {
Map model = (Map) session.getAttribute(MODEL_KEY);
if (model == null) {
logger.warn("Null model map for session: " + session.getId());
}
return model;
}
/*** Private class internals ***/
private void initEvent(AjaxEvent event, HttpServletRequest request) {
String paramsString = request.getParameter(this.jsonParamsParameter);
if (paramsString != null) {
Map<String, String> parameters = new HashMap<String, String>();
JSONObject json = JSONObject.fromString(paramsString);
Iterator keys = json.keys();
while (keys.hasNext()) {
String key = keys.next().toString();
parameters.put(key, json.opt(key).toString());
}
event.setParameters(parameters);
}
event.setElementName(request.getParameter(this.elementParameter));
event.setElementId(request.getParameter(this.elementIdParameter));
}
private void redirectToView(String view, HttpServletRequest request, HttpServletResponse response, ModelAndView modelAndView) throws IOException {
// Creating Ajax redirect action:
AjaxResponse ajaxResponse = new AjaxResponseImpl();
AjaxAction ajaxAction = new RedirectAction(new StringBuilder(request.getContextPath()).append(view).toString(), modelAndView);
ajaxResponse.addAction(ajaxAction);
// Need to clear the ModelAndView because we are handling the response by ourselves:
modelAndView.clear();
AjaxResponseSender.sendResponse(response, ajaxResponse);
}
}