org.springframework.webflow.mvc.view.AbstractMvcView.java Source code

Java tutorial

Introduction

Here is the source code for org.springframework.webflow.mvc.view.AbstractMvcView.java

Source

/*
 * Copyright 2004-2012 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.springframework.webflow.mvc.view;

import java.io.IOException;
import java.io.Serializable;
import java.lang.reflect.Array;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.binding.convert.ConversionExecutor;
import org.springframework.binding.convert.ConversionService;
import org.springframework.binding.expression.EvaluationException;
import org.springframework.binding.expression.Expression;
import org.springframework.binding.expression.ExpressionParser;
import org.springframework.binding.expression.ParserContext;
import org.springframework.binding.expression.support.FluentParserContext;
import org.springframework.binding.expression.support.StaticExpression;
import org.springframework.binding.mapping.MappingResult;
import org.springframework.binding.mapping.MappingResults;
import org.springframework.binding.mapping.MappingResultsCriteria;
import org.springframework.binding.mapping.impl.DefaultMapper;
import org.springframework.binding.mapping.impl.DefaultMapping;
import org.springframework.binding.message.MessageBuilder;
import org.springframework.binding.message.MessageResolver;
import org.springframework.core.style.ToStringCreator;
import org.springframework.util.Assert;
import org.springframework.validation.BindingResult;
import org.springframework.validation.MessageCodesResolver;
import org.springframework.validation.Validator;
import org.springframework.web.util.WebUtils;
import org.springframework.webflow.core.collection.AttributeMap;
import org.springframework.webflow.core.collection.ParameterMap;
import org.springframework.webflow.definition.TransitionDefinition;
import org.springframework.webflow.engine.builder.BinderConfiguration;
import org.springframework.webflow.engine.builder.BinderConfiguration.Binding;
import org.springframework.webflow.execution.Event;
import org.springframework.webflow.execution.FlowExecutionKey;
import org.springframework.webflow.execution.RequestContext;
import org.springframework.webflow.execution.View;
import org.springframework.webflow.validation.ValidationHelper;

/**
 * Base view implementation for the Spring Web MVC Servlet and Spring Web MVC Portlet frameworks.
 * 
 * @author Keith Donald
 */
public abstract class AbstractMvcView implements View {

    private static final Log logger = LogFactory.getLog(AbstractMvcView.class);

    private static final MappingResultsCriteria PROPERTY_NOT_FOUND_ERROR = new PropertyNotFoundError();

    private static final MappingResultsCriteria MAPPING_ERROR = new MappingError();

    private org.springframework.web.servlet.View view;

    private RequestContext requestContext;

    private ExpressionParser expressionParser;

    private ConversionService conversionService;

    private Validator validator;

    private String fieldMarkerPrefix = "_";

    private String eventIdParameterName = "_eventId";

    private String eventId;

    private MappingResults mappingResults;

    private BinderConfiguration binderConfiguration;

    private MessageCodesResolver messageCodesResolver;

    private boolean userEventProcessed;

    /**
     * Creates a new MVC view.
     * @param view the Spring MVC view to render
     * @param requestContext the current flow request context
     */
    public AbstractMvcView(org.springframework.web.servlet.View view, RequestContext requestContext) {
        this.view = view;
        this.requestContext = requestContext;
    }

    /**
     * Sets the expression parser to use to parse model expressions.
     * @param expressionParser the expression parser
     */
    public void setExpressionParser(ExpressionParser expressionParser) {
        this.expressionParser = expressionParser;
    }

    /**
     * Sets the service to use to expose formatters for field values.
     * @param conversionService the conversion service
     */
    public void setConversionService(ConversionService conversionService) {
        this.conversionService = conversionService;
    }

    public void setValidator(Validator validator) {
        this.validator = validator;
    }

    /**
     * Sets the configuration describing how this view should bind to its model to access data for rendering.
     * @param binderConfiguration the model binder configuration
     */
    public void setBinderConfiguration(BinderConfiguration binderConfiguration) {
        this.binderConfiguration = binderConfiguration;
    }

    /**
     * Set the message codes resolver to use to resolve bind and validation failure message codes.
     * @param messageCodesResolver the binding error message code resolver to use
     */
    public void setMessageCodesResolver(MessageCodesResolver messageCodesResolver) {
        this.messageCodesResolver = messageCodesResolver;
    }

    /**
     * Specify a prefix that can be used for parameters that mark potentially empty fields, having "prefix + field" as
     * name. Such a marker parameter is checked by existence: You can send any value for it, for example "visible". This
     * is particularly useful for HTML checkboxes and select options.
     * <p>
     * Default is "_", for "_FIELD" parameters (e.g. "_subscribeToNewsletter"). Set this to null if you want to turn off
     * the empty field check completely.
     * <p>
     * HTML checkboxes only send a value when they're checked, so it is not possible to detect that a formerly checked
     * box has just been unchecked, at least not with standard HTML means.
     * <p>
     * This auto-reset mechanism addresses this deficiency, provided that a marker parameter is sent for each checkbox
     * field, like "_subscribeToNewsletter" for a "subscribeToNewsletter" field. As the marker parameter is sent in any
     * case, the data binder can detect an empty field and automatically reset its value.
     */
    public void setFieldMarkerPrefix(String fieldMarkerPrefix) {
        this.fieldMarkerPrefix = fieldMarkerPrefix;
    }

    /**
     * Sets the name of the request parameter to use to lookup user events signaled by this view. If not specified, the
     * default is <code>_eventId</code>
     * @param eventIdParameterName the event id parameter name
     */
    public void setEventIdParameterName(String eventIdParameterName) {
        this.eventIdParameterName = eventIdParameterName;
    }

    public void render() throws IOException {
        Map<String, Object> model = new HashMap<String, Object>();
        model.putAll(flowScopes());
        exposeBindingModel(model);
        model.put("flowRequestContext", requestContext);
        FlowExecutionKey key = requestContext.getFlowExecutionContext().getKey();
        if (key != null) {
            model.put("flowExecutionKey", requestContext.getFlowExecutionContext().getKey().toString());
            model.put("flowExecutionUrl", requestContext.getFlowExecutionUrl());
        }
        model.put("currentUser", requestContext.getExternalContext().getCurrentUser());
        try {
            if (logger.isDebugEnabled()) {
                logger.debug("Rendering MVC [" + view + "] with model map [" + model + "]");
            }
            doRender(model);
        } catch (IOException e) {
            throw e;
        } catch (Exception e) {
            IllegalStateException ise = new IllegalStateException("Exception occurred rendering view " + view);
            ise.initCause(e);
            throw ise;
        }
    }

    public boolean userEventQueued() {
        return !userEventProcessed && getEventId() != null;
    }

    public void processUserEvent() {
        String eventId = getEventId();
        if (eventId == null) {
            return;
        }
        if (logger.isDebugEnabled()) {
            logger.debug("Processing user event '" + eventId + "'");
        }
        Object model = getModelObject();
        if (model != null) {
            if (logger.isDebugEnabled()) {
                logger.debug("Resolved model " + model);
            }
            TransitionDefinition transition = requestContext.getMatchingTransition(eventId);
            if (shouldBind(model, transition)) {
                mappingResults = bind(model);
                if (hasErrors(mappingResults)) {
                    if (logger.isDebugEnabled()) {
                        logger.debug("Model binding resulted in errors; adding error messages to context");
                    }
                    addErrorMessages(mappingResults);
                }
                if (shouldValidate(model, transition)) {
                    validate(model);
                }
            }
        } else {
            if (logger.isDebugEnabled()) {
                logger.debug("No model to bind to; done processing user event");
            }
        }
        userEventProcessed = true;
    }

    public Serializable getUserEventState() {
        return new ViewActionStateHolder(eventId, userEventProcessed, mappingResults);
    }

    public boolean hasFlowEvent() {
        return userEventProcessed && !requestContext.getMessageContext().hasErrorMessages();
    }

    public Event getFlowEvent() {
        if (!hasFlowEvent()) {
            return null;
        }
        return new Event(this, getEventId(), requestContext.getRequestParameters().asAttributeMap());
    }

    public void saveState() {

    }

    public String toString() {
        return new ToStringCreator(this).append("view", view).toString();
    }

    // subclassing hooks

    /**
     * Returns the current flow request context.
     * @return the flow request context
     */
    protected RequestContext getRequestContext() {
        return requestContext;
    }

    /**
     * Returns the Spring MVC view to render
     * @return the view
     */
    protected org.springframework.web.servlet.View getView() {
        return view;
    }

    /**
     * Template method subclasses should override to execute the view rendering logic.
     * @param model the view model data
     * @throws Exception an exception occurred rendering the view
     */
    protected abstract void doRender(Map<String, ?> model) throws Exception;

    /**
     * Returns the id of the user event being processed.
     * @return the user event
     */
    protected String getEventId() {
        if (eventId == null) {
            eventId = determineEventId(requestContext);
        }
        return this.eventId;
    }

    /**
     * Determines if model data binding should be invoked given the Transition that matched the current user event being
     * processed. Returns true unless the <code>bind</code> attribute of the Transition has been set to false.
     * Subclasses may override.
     * @param model the model data binding would be performed on
     * @param transition the matched transition
     * @return true if binding should occur, false if not
     */
    protected boolean shouldBind(Object model, TransitionDefinition transition) {
        if (transition == null) {
            return true;
        }
        return transition.getAttributes().getBoolean("bind", true);
    }

    /**
     * Returns the results of binding to the view's model, if model binding has occurred.
     * @return the binding (mapping) results
     */
    protected MappingResults getMappingResults() {
        return mappingResults;
    }

    /**
     * Returns the binding configuration that defines how to connect properties of the model to UI elements.
     * @return an instance of {@link BinderConfiguration} or null.
     */
    protected BinderConfiguration getBinderConfiguration() {
        return binderConfiguration;
    }

    /**
     * Returns the EL parser to be used for data binding purposes.
     * @return an instance of {@link ExpressionParser}.
     */
    protected ExpressionParser getExpressionParser() {
        return expressionParser;
    }

    /**
     * Returns the prefix that can be used for parameters that mark potentially empty fields.
     * @return the prefix value.
     */
    protected String getFieldMarkerPrefix() {
        return fieldMarkerPrefix;
    }

    /**
     * Obtain the user event from the current flow request. The default implementation returns the value of the request
     * parameter with name {@link #setEventIdParameterName(String) eventIdParameterName}. Subclasses may override.
     * @param context the current flow request context
     * @return the user event that occurred
     */
    protected String determineEventId(RequestContext context) {
        return WebUtils.findParameterValue(context.getRequestParameters().asMap(), eventIdParameterName);
    }

    /**
     * <p>
     * Causes the model to be populated from information contained in request parameters.
     * </p>
     * <p>
     * If a view has binding configuration then only model fields specified in the binding configuration will be
     * considered. In the absence of binding configuration all request parameters will be used to update matching fields
     * on the model.
     * </p>
     * 
     * @param model the model to be updated
     * @return an instance of MappingResults with information about the results of the binding.
     */
    protected MappingResults bind(Object model) {
        if (logger.isDebugEnabled()) {
            logger.debug("Binding to model");
        }
        DefaultMapper mapper = new DefaultMapper();
        ParameterMap requestParameters = requestContext.getRequestParameters();
        if (binderConfiguration != null) {
            addModelBindings(mapper, requestParameters.asMap().keySet(), model);
        } else {
            addDefaultMappings(mapper, requestParameters.asMap().keySet(), model);
        }
        return mapper.map(requestParameters, model);
    }

    /**
     * <p>
     * Adds a {@link DefaultMapping} for every configured view {@link Binding} for which there is an incoming request
     * parameter. If there is no matching incoming request parameter, a special mapping is created that will set the
     * target field on the model to an empty value (typically null).
     * </p>
     * 
     * @param mapper the mapper to which mappings will be added
     * @param parameterNames the request parameters
     * @param model the model
     */
    protected void addModelBindings(DefaultMapper mapper, Set<String> parameterNames, Object model) {
        for (Binding binding : binderConfiguration.getBindings()) {
            String parameterName = binding.getProperty();
            if (parameterNames.contains(parameterName)) {
                addMapping(mapper, binding, model);
            } else {
                if (fieldMarkerPrefix != null && parameterNames.contains(fieldMarkerPrefix + parameterName)) {
                    addEmptyValueMapping(mapper, parameterName, model);
                }
            }
        }
    }

    /**
     * <p>
     * Creates and adds a {@link DefaultMapping} for the given {@link Binding}. Information such as the model field
     * name, if the field is required, and whether type conversion is needed will be passed on from the binding to the
     * mapping.
     * </p>
     * <p>
     * <b>Note:</b> with Spring 3 type conversion and formatting now in use in Web Flow, it is no longer necessary to
     * use named converters on binding elements. The preferred approach is to register Spring 3 formatters. Named
     * converters are supported for backwards compatibility only and will not result in use of the Spring 3 type
     * conversion system at runtime.
     * </p>
     * 
     * @param mapper the mapper to add the mapping to
     * @param binding the binding element
     * @param model the model
     */
    protected void addMapping(DefaultMapper mapper, Binding binding, Object model) {
        Expression source = new RequestParameterExpression(binding.getProperty());
        ParserContext parserContext = new FluentParserContext().evaluate(model.getClass());
        Expression target = expressionParser.parseExpression(binding.getProperty(), parserContext);
        DefaultMapping mapping = new DefaultMapping(source, target);
        mapping.setRequired(binding.getRequired());
        if (binding.getConverter() != null) {
            Assert.notNull(conversionService,
                    "A ConversionService must be configured to use resolve custom converters to use during binding");
            ConversionExecutor conversionExecutor = conversionService.getConversionExecutor(binding.getConverter(),
                    String.class, target.getValueType(model));
            mapping.setTypeConverter(conversionExecutor);
        }
        if (logger.isDebugEnabled()) {
            logger.debug("Adding mapping for parameter '" + binding.getProperty() + "'");
        }
        mapper.addMapping(mapping);
    }

    /**
     * Add a {@link DefaultMapping} instance for all incoming request parameters except those having a special field
     * marker prefix. This method is used when binding configuration was not specified on the view.
     * 
     * @param mapper the mapper to add mappings to
     * @param parameterNames the request parameter names
     * @param model the model
     */
    protected void addDefaultMappings(DefaultMapper mapper, Set<String> parameterNames, Object model) {
        for (String parameterName : parameterNames) {
            if (fieldMarkerPrefix != null && parameterName.startsWith(fieldMarkerPrefix)) {
                String field = parameterName.substring(fieldMarkerPrefix.length());
                if (!parameterNames.contains(field)) {
                    addEmptyValueMapping(mapper, field, model);
                }
            } else {
                addDefaultMapping(mapper, parameterName, model);
            }
        }
    }

    /**
     * Adds a special {@link DefaultMapping} that results in setting the target field on the model to an empty value
     * (typically null).
     * 
     * @param mapper the mapper to add the mapping to
     * @param field the field for which a mapping is to be added
     * @param model the model
     */
    protected void addEmptyValueMapping(DefaultMapper mapper, String field, Object model) {
        ParserContext parserContext = new FluentParserContext().evaluate(model.getClass());
        Expression target = expressionParser.parseExpression(field, parserContext);
        try {
            Class<?> propertyType = target.getValueType(model);
            Expression source = new StaticExpression(getEmptyValue(propertyType));
            DefaultMapping mapping = new DefaultMapping(source, target);
            if (logger.isDebugEnabled()) {
                logger.debug("Adding empty value mapping for parameter '" + field + "'");
            }
            mapper.addMapping(mapping);
        } catch (EvaluationException e) {
        }
    }

    /**
     * Adds a {@link DefaultMapping} between the given request parameter name and a matching model field.
     * 
     * @param mapper the mapper to add the mapping to
     * @param parameter the request parameter name
     * @param model the model
     */
    protected void addDefaultMapping(DefaultMapper mapper, String parameter, Object model) {
        Expression source = new RequestParameterExpression(parameter);
        ParserContext parserContext = new FluentParserContext().evaluate(model.getClass());
        Expression target = expressionParser.parseExpression(parameter, parserContext);
        DefaultMapping mapping = new DefaultMapping(source, target);
        if (logger.isDebugEnabled()) {
            logger.debug("Adding default mapping for parameter '" + parameter + "'");
        }
        mapper.addMapping(mapping);
    }

    // package private

    /**
     * Restores the internal state of this view from the provided state holder.
     * @see AbstractMvcViewFactory#getView(RequestContext)
     */
    void restoreState(ViewActionStateHolder stateHolder) {
        eventId = stateHolder.getEventId();
        userEventProcessed = stateHolder.getUserEventProcessed();
        mappingResults = stateHolder.getMappingResults();
    }

    /**
     * Determines if model validation should execute given the Transition that matched the current user event being
     * processed. Returns true unless the <code>validate</code> attribute of the Transition has been set to false, or
     * model data binding errors occurred and the global <code>validateOnBindingErrors</code> flag is set to false.
     * Subclasses may override.
     * @param model the model data binding would be performed on
     * @param transition the matched transition
     * @return true if binding should occur, false if not
     */
    protected boolean shouldValidate(Object model, TransitionDefinition transition) {
        Boolean validateAttribute = getValidateAttribute(transition);
        if (validateAttribute != null) {
            return validateAttribute;
        } else {
            AttributeMap<Object> flowExecutionAttributes = requestContext.getFlowExecutionContext().getAttributes();
            Boolean validateOnBindingErrors = flowExecutionAttributes.getBoolean("validateOnBindingErrors");
            if (validateOnBindingErrors != null) {
                if (!validateOnBindingErrors && mappingResults.hasErrorResults()) {
                    return false;
                }
            }
            return true;
        }
    }

    // internal helpers

    private Map<String, Object> flowScopes() {
        if (requestContext.getCurrentState().isViewState()) {
            return requestContext.getConversationScope().union(requestContext.getFlowScope())
                    .union(requestContext.getViewScope()).union(requestContext.getFlashScope())
                    .union(requestContext.getRequestScope()).asMap();
        } else {
            return requestContext.getConversationScope().union(requestContext.getFlowScope())
                    .union(requestContext.getFlashScope()).union(requestContext.getRequestScope()).asMap();
        }
    }

    private void exposeBindingModel(Map<String, Object> model) {
        Object modelObject = getModelObject();
        if (modelObject != null) {
            BindingModel bindingModel = new BindingModel(getModelExpression().getExpressionString(), modelObject,
                    expressionParser, conversionService, requestContext.getMessageContext());
            bindingModel.setBinderConfiguration(binderConfiguration);
            bindingModel.setMappingResults(mappingResults);
            model.put(BindingResult.MODEL_KEY_PREFIX + getModelExpression().getExpressionString(), bindingModel);
        }
    }

    private Object getModelObject() {
        Expression model = getModelExpression();
        if (model != null) {
            try {
                return model.getValue(requestContext);
            } catch (EvaluationException e) {
                return null;
            }
        } else {
            return null;
        }
    }

    private Expression getModelExpression() {
        return (Expression) requestContext.getCurrentState().getAttributes().get("model");
    }

    private Object getEmptyValue(Class<?> fieldType) {
        if (fieldType != null && boolean.class.equals(fieldType) || Boolean.class.equals(fieldType)) {
            // Special handling of boolean property.
            return false;
        } else if (fieldType != null && fieldType.isArray()) {
            // Special handling of array property.
            return Array.newInstance(fieldType.getComponentType(), 0);
        } else {
            // Default value: try null.
            return null;
        }
    }

    private boolean hasErrors(MappingResults results) {
        return results.hasErrorResults() && !onlyPropertyNotFoundErrorsPresent(results);
    }

    private boolean onlyPropertyNotFoundErrorsPresent(MappingResults results) {
        return results.getResults(PROPERTY_NOT_FOUND_ERROR).size() == mappingResults.getErrorResults().size();
    }

    private void addErrorMessages(MappingResults results) {
        List<MappingResult> errors = results.getResults(MAPPING_ERROR);
        for (MappingResult error : errors) {
            requestContext.getMessageContext().addMessage(createMessageResolver(error));
        }
    }

    private MessageResolver createMessageResolver(MappingResult error) {
        String model = getModelExpression().getExpressionString();
        String field = error.getMapping().getTargetExpression().getExpressionString();
        Class<?> fieldType = error.getMapping().getTargetExpression().getValueType(getModelObject());
        String[] messageCodes = messageCodesResolver.resolveMessageCodes(error.getCode(), model, field, fieldType);
        return new MessageBuilder().error().source(field).codes(messageCodes).resolvableArg(field)
                .defaultText(error.getCode() + " on " + field).build();
    }

    private Boolean getValidateAttribute(TransitionDefinition transition) {
        if (transition != null) {
            return transition.getAttributes().getBoolean("validate");
        } else {
            return null;
        }
    }

    private void validate(Object model) {
        if (logger.isDebugEnabled()) {
            logger.debug("Validating model");
        }
        ValidationHelper helper = new ValidationHelper(model, requestContext, eventId,
                getModelExpression().getExpressionString(), expressionParser, messageCodesResolver, mappingResults);
        helper.setValidator(validator);
        helper.validate();
    }

    private static class PropertyNotFoundError implements MappingResultsCriteria {
        public boolean test(MappingResult result) {
            return result.isError() && "propertyNotFound".equals(result.getCode());
        }
    }

    private static class MappingError implements MappingResultsCriteria {
        public boolean test(MappingResult result) {
            return result.isError() && !PROPERTY_NOT_FOUND_ERROR.test(result);
        }
    }

    private static class RequestParameterExpression implements Expression {

        private String parameterName;

        public RequestParameterExpression(String parameterName) {
            this.parameterName = parameterName;
        }

        public String getExpressionString() {
            return parameterName;
        }

        public Object getValue(Object context) throws EvaluationException {
            ParameterMap parameters = (ParameterMap) context;
            return parameters.asMap().get(parameterName);
        }

        public Class<?> getValueType(Object context) {
            return String.class;
        }

        public void setValue(Object context, Object value) throws EvaluationException {
            throw new UnsupportedOperationException("Setting request parameters is not allowed");
        }

        public String toString() {
            return "parameter:'" + parameterName + "'";
        }
    }

}