/* * Copyright 2017 OmniFaces * * 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.omnifaces.taghandler; import static java.util.logging.Level.SEVERE; import static javax.faces.component.visit.VisitHint.SKIP_UNRENDERED; import static javax.faces.event.PhaseId.PROCESS_VALIDATIONS; import static javax.faces.event.PhaseId.RESTORE_VIEW; import static javax.faces.event.PhaseId.UPDATE_MODEL_VALUES; import static javax.faces.view.facelets.ComponentHandler.isNew; import static org.omnifaces.el.ExpressionInspector.getValueReference; import static org.omnifaces.util.Components.forEachComponent; import static org.omnifaces.util.Components.getClosestParent; import static org.omnifaces.util.Components.getCurrentForm; import static org.omnifaces.util.Components.getLabel; import static org.omnifaces.util.Components.hasInvokedSubmit; import static org.omnifaces.util.Events.subscribeToRequestAfterPhase; import static org.omnifaces.util.Events.subscribeToRequestBeforePhase; import static org.omnifaces.util.Events.subscribeToViewEvent; import static org.omnifaces.util.Facelets.getBoolean; import static org.omnifaces.util.Facelets.getString; import static org.omnifaces.util.Facelets.getValueExpression; import static org.omnifaces.util.Faces.getELContext; import static org.omnifaces.util.Faces.renderResponse; import static org.omnifaces.util.Faces.validationFailed; import static org.omnifaces.util.FacesLocal.evaluateExpressionGet; import static org.omnifaces.util.Messages.addError; import static org.omnifaces.util.Messages.addGlobalError; import static org.omnifaces.util.Platform.getBeanValidator; import static org.omnifaces.util.Reflection.instance; import static org.omnifaces.util.Reflection.setProperties; import static org.omnifaces.util.Reflection.toClass; import static org.omnifaces.util.Utils.coalesce; import static org.omnifaces.util.Utils.csvToList; import static org.omnifaces.util.Utils.isEmpty; import java.io.IOException; import java.io.Serializable; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Logger; import javax.el.ValueExpression; import javax.el.ValueReference; import javax.faces.FacesException; import javax.faces.component.UICommand; import javax.faces.component.UIComponent; import javax.faces.component.UIForm; import javax.faces.component.UIInput; import javax.faces.context.FacesContext; import javax.faces.event.PostValidateEvent; import javax.faces.event.PreValidateEvent; import javax.faces.event.SystemEventListener; import javax.faces.validator.Validator; import javax.faces.view.facelets.FaceletContext; import javax.faces.view.facelets.TagConfig; import javax.faces.view.facelets.TagHandler; import javax.validation.ConstraintViolation; import org.omnifaces.eventlistener.BeanValidationEventListener; import org.omnifaces.util.Callback; import org.omnifaces.util.copier.CloneCopier; import org.omnifaces.util.copier.Copier; import org.omnifaces.util.copier.CopyCtorCopier; import org.omnifaces.util.copier.MultiStrategyCopier; import org.omnifaces.util.copier.NewInstanceCopier; import org.omnifaces.util.copier.SerializationCopier; /** * <p> * The <code><o:validateBean></code> allows the developer to control bean validation on a per-{@link UICommand} * or {@link UIInput} component basis, as well as validating a given bean at the class level. * * <p> * The standard <code><f:validateBean></code> only allows validation control on a per-form * or a per-request basis (by using multiple tags and conditional EL expressions in its attributes) which may end up in * boilerplate code. * * <p> * The standard <code><f:validateBean></code> also, despite its name, does not actually have any facilities to * validate a bean at all. * * <h3>Usage</h3> * <p> * Some examples * * <p> * <b>Control bean validation per component</b> * <pre> * <h:commandButton value="submit" action="#{bean.submit}"> * <o:validateBean validationGroups="javax.validation.groups.Default,com.example.MyGroup" /> * </h:commandButton> * </pre> * <pre> * <h:selectOneMenu value="#{bean.selectedItem}"> * <f:selectItems value="#{bean.availableItems}" /> * <o:validateBean disabled="true" /> * <f:ajax execute="@form" listener="#{bean.itemChanged}" render="@form" /> * </h:selectOneMenu> * </pre> * * <p> * <b>Validate a bean at the class level</b> * <pre> * <h:inputText value="#{bean.product.item}" /> * <h:inputText value="#{bean.product.order}" /> * * <o:validateBean value="#{bean.product}" validationGroups="com.example.MyGroup" /> * </pre> * * <h3>Class level validation details</h3> * <p> * In order to validate a bean at the class level, all values from input components should first be actually set on that bean * and only thereafter should the bean be validated. This however does not play well with the JSF approach where a model * is only updated when validation passes. But for class level validation we seemingly can not validate until the model * is updated. To break this tie, a <em>copy</em> of the model bean is made first, and then values are stored in this copy * and validated there. If validation passes, the original bean is updated. * * <p> * A bean is copied using the following strategies (in the order indicated): * <ol> * <li> <b>Cloning</b> - Bean must implement the {@link Cloneable} interface and support cloning according to the rules of that interface. See {@link CloneCopier} * <li> <b>Serialization</b> - Bean must implement the {@link Serializable} interface and support serialization according to the rules of that interface. See {@link SerializationCopier} * <li> <b>Copy constructor</b> - Bean must have an additional constructor (next to the default constructor) taking a single argument of its own * type that initializes itself with the values of that passed in type. See {@link CopyCtorCopier} * <li> <b>New instance</b> - Bean should have a public no arguments (default) constructor. Every official JavaBean satisfies this requirement. Note * that in this case no copy is made of the original bean, but just a new instance is created. See {@link NewInstanceCopier} * </ol> * * <p> * If the above order is not ideal, or if an custom copy strategy is needed (e.g. when it's only needed to copy a few fields for the validation) * a strategy can be supplied explicitly via the <code>copier</code> attribute. The value of this attribute can be any of the build-in copier implementations * given above, or can be a custom implementation of the {@link Copier} interface. * <p> * If the copying strategy is not possible due to technical limitations, then you could set <code>method</code> * attribute to <code>"validateActual"</code>. * <pre> * <o:validateBean value="#{bean.product}" validationGroups="com.example.MyGroup" method="validateActual" /> * </pre> * <p> * This will update the model values and run the validation after update model values phase instead of the validations * phase. The disadvantage is that the invalid values remain in the model and that the action method is anyway invoked. * You would need an additional check for {@link FacesContext#isValidationFailed()} in the action method to see if it * has failed or not. * * <h3>Faces messages</h3> * <p> * By default, the faces message is added with client ID of the parent {@link UIForm}. * <pre> * <h:form id="formId"> * ... * <h:message for="formId" /> * <o:validateBean ... /> * </h:form> * </pre> * <p> * The faces message can also be shown for all invalidated components using <code>showMessageFor="@all"</code>. * <pre> * <h:form> * <h:inputText id="foo" /> * <h:message for="foo" /> * <h:inputText id="bar" /> * <h:message for="bar" /> * ... * <o:validateBean ... showMessageFor="@all" /> * </h:form> * </pre> * <p> * The faces message can also be shown as global message using <code>showMessageFor="@global"</code>. * <pre> * <h:form> * ... * <o:validateBean ... showMessageFor="@global" /> * </h:form> * <h:messages globalOnly="true" /> * </pre> * <p> * The faces message can also be shown for specific components referenced by a space separated collection of their * client IDs in <code>showMessageFor</code> attribute. * <pre> * <h:form> * <h:inputText id="foo" /> * <h:message for="foo" /> * <h:inputText id="bar" /> * <h:message for="bar" /> * ... * <o:validateBean ... showMessageFor="foo bar" /> * </h:form> * </pre> * <p> * The faces message can also be shown for components which match {@link ConstraintViolation#getPropertyPath() Property * Path of the ConstraintViolation} using <code>showMessageFor="@violating"</code>, and when no matching component can * be found, the message will fallback to being added with client ID of the parent {@link UIForm}. * <pre> * <h:form id="formId"> * ... * <!-- Unmatched messages shown here: --> * <h:message for="formId" /> * ... * <h:inputText id="foo" value="#{bean.product.item}" /> * * <!-- Messages where ConstraintViolation PropertyPath is "item" are shown here: --> * <h:message for="foo" /> * ... * <o:validateBean ... value="#{bean.product}" showMessageFor="@violating" /> * </h:form> * </pre> * <p> * The <code>showMessageFor</code> attribute is new since OmniFaces 2.6 and it defaults to <code>@form</code>. The * <code>showMessageFor</code> attribute does by design not have any effect when <code>validateMethod="actual"</code> * is used. * * @author Bauke Scholtz * @author Arjan Tijms * @see BeanValidationEventListener */ public class ValidateBean extends TagHandler { // Constants ------------------------------------------------------------------------------------------------------ private static final Logger logger = Logger.getLogger(ValidateBean.class.getName()); private static final String DEFAULT_SHOWMESSAGEFOR = "@form"; private static final String VALUE_ATTRIBUTE = "value"; private static final String ERROR_MISSING_FORM = "o:validateBean must be nested in an UIForm."; private static final String ERROR_INVALID_PARENT = "o:validateBean parent must be an instance of UIInput or UICommand."; // Enums ---------------------------------------------------------------------------------------------------------- private enum ValidateMethod { validateCopy, validateActual; public static ValidateMethod of(String name) { if (isEmpty(name)) { return validateCopy; } return valueOf(name); } } // Variables ------------------------------------------------------------------------------------------------------ private ValueExpression value; private boolean disabled; private ValidateMethod method; private String groups; private String copier; private String showMessageFor; // Constructors --------------------------------------------------------------------------------------------------- /** * The tag constructor. * @param config The tag config. */ public ValidateBean(TagConfig config) { super(config); } // Actions -------------------------------------------------------------------------------------------------------- /** * If the parent component has the <code>value</code> attribute or is an instance of {@link UICommand} or * {@link UIInput} and is new and we're in the restore view phase of a postback, then delegate to * {@link #processValidateBean(FacesContext, UIComponent)}. * @throws IllegalArgumentException When the <code>value</code> attribute is absent and the parent component is not * an instance of {@link UICommand} or {@link UIInput}. */ @Override public void apply(FaceletContext context, final UIComponent parent) throws IOException { if (getAttribute(VALUE_ATTRIBUTE) == null && (!(parent instanceof UICommand || parent instanceof UIInput))) { throw new IllegalArgumentException(ERROR_INVALID_PARENT); } final FacesContext facesContext = context.getFacesContext(); if (!(isNew(parent) && facesContext.isPostback() && facesContext.getCurrentPhaseId() == RESTORE_VIEW)) { return; } value = getValueExpression(context, getAttribute(VALUE_ATTRIBUTE), Object.class); disabled = getBoolean(context, getAttribute("disabled")); method = ValidateMethod.of(getString(context, getAttribute("method"))); groups = getString(context, getAttribute("validationGroups")); copier = getString(context, getAttribute("copier")); showMessageFor = coalesce(getString(context, getAttribute("showMessageFor")), DEFAULT_SHOWMESSAGEFOR); // We can't use getCurrentForm() or hasInvokedSubmit() before the component is added to view, because the client ID isn't available. // Hence, we subscribe this check to after phase of restore view. subscribeToRequestAfterPhase(RESTORE_VIEW, new Callback.Void() { @Override public void invoke() { processValidateBean(facesContext, parent); }}); } /** * Check if the given component has participated in submitting the current form or action and if so, then perform * the bean validation depending on the attributes set. * @param context The involved faces context. * @param component The involved component. * @throws IllegalStateException When the parent form is missing. */ protected void processValidateBean(FacesContext context, UIComponent component) { UIForm form = (component instanceof UIForm) ? ((UIForm) component) : getClosestParent(component, UIForm.class); if (form == null) { throw new IllegalStateException(ERROR_MISSING_FORM); } if (!form.equals(getCurrentForm()) || (!(component instanceof UIForm) && !hasInvokedSubmit(component))) { return; } Object bean = null; if (value != null) { final Object[] found = new Object[1]; forEachComponent(context).fromRoot(form).invoke(new Callback.WithArgument<UIComponent>() { @Override public void invoke(UIComponent target) { found[0] = value.getValue(getELContext()); }}); bean = found[0]; } if (bean == null) { validateForm(); return; } if (!disabled) { if (method == ValidateMethod.validateActual) { validateActualBean(form, bean); } else { validateCopiedBean(form, bean); } } } /** * After update model values phase, validate actual bean. But don't proceed to render response on fail. */ private void validateActualBean(final UIForm form, final Object bean) { ValidateBeanCallback validateActualBean = new ValidateBeanCallback() { @Override public void run() { FacesContext context = FacesContext.getCurrentInstance(); validate(context, form, bean, bean, new HashSet<String>(0), false); }}; subscribeToRequestAfterPhase(UPDATE_MODEL_VALUES, validateActualBean); } /** * Before validations phase of current request, collect all bean properties. * * After validations phase of current request, create a copy of the bean, set all collected properties there, * then validate copied bean and proceed to render response on fail. */ private void validateCopiedBean(final UIForm form, final Object bean) { final Set<String> clientIds = new HashSet<>(); final Map<String, Object> properties = new HashMap<>(); ValidateBeanCallback collectBeanProperties = new ValidateBeanCallback() { @Override public void run() { FacesContext context = FacesContext.getCurrentInstance(); forEachInputWithMatchingBase(context, form, bean, new Callback.WithArgument<UIInput>() { @Override public void invoke(UIInput input) { addCollectingValidator(input, clientIds, properties); }}); }}; ValidateBeanCallback checkConstraints = new ValidateBeanCallback() { @Override public void run() { FacesContext context = FacesContext.getCurrentInstance(); forEachInputWithMatchingBase(context, form, bean, new Callback.WithArgument<UIInput>() { @Override public void invoke(UIInput input) { removeCollectingValidator(input); }}); Object copiedBean = getCopier(context, copier).copy(bean); setProperties(copiedBean, properties); validate(context, form, bean, copiedBean, clientIds, true); }}; subscribeToRequestBeforePhase(PROCESS_VALIDATIONS, collectBeanProperties); subscribeToRequestAfterPhase(PROCESS_VALIDATIONS, checkConstraints); } /** * Before validations phase of current request, subscribe the {@link BeanValidationEventListener} to validate the form based on groups. */ private void validateForm() { ValidateBeanCallback validateForm = new ValidateBeanCallback() { @Override public void run() { SystemEventListener listener = new BeanValidationEventListener(groups, disabled); subscribeToViewEvent(PreValidateEvent.class, listener); subscribeToViewEvent(PostValidateEvent.class, listener); }}; subscribeToRequestBeforePhase(PROCESS_VALIDATIONS, validateForm); } @SuppressWarnings({ "unchecked", "rawtypes" }) private void validate(FacesContext context, UIForm form, Object actualBean, Object validableBean, Set<String> clientIds, boolean renderResponseOnFail) { List<Class> groupClasses = new ArrayList<>(); for (String group : csvToList(groups)) { groupClasses.add(toClass(group)); } Set violationsRaw = getBeanValidator().validate(validableBean, groupClasses.toArray(new Class[groupClasses.size()])); Set<ConstraintViolation<?>> violations = violationsRaw; if (!violations.isEmpty()) { context.validationFailed(); String showMessagesFor = showMessageFor; if ("@violating".equals(showMessageFor)) { violations = invalidateInputsByPropertyPathAndShowMessages(context, form, actualBean, violations, clientIds); showMessagesFor = DEFAULT_SHOWMESSAGEFOR; } if (!violations.isEmpty()) { String labels = invalidateInputsByClientIdsAndCollectLabels(context, form, clientIds); showMessages(context, form, violations, clientIds, labels, showMessagesFor); } if (renderResponseOnFail) { context.renderResponse(); } } } // Helpers -------------------------------------------------------------------------------------------------------- private static void forEachInputWithMatchingBase(final FacesContext context, UIComponent form, final Object base, final String property, final Callback.WithArgument<UIInput> callback) { forEachComponent(context) .fromRoot(form) .ofTypes(UIInput.class) .withHints(SKIP_UNRENDERED/*, SKIP_ITERATION*/) // SKIP_ITERATION fails in Apache EL (Tomcat 8.0.32 tested) but works in Oracle EL. .invoke(new Callback.WithArgument<UIInput>() { @Override public void invoke(UIInput input) { ValueExpression valueExpression = input.getValueExpression(VALUE_ATTRIBUTE); if (valueExpression != null) { ValueReference valueReference = getValueReference(context.getELContext(), valueExpression); if (valueReference.getBase().equals(base) && (property == null || property.equals(valueReference.getProperty()))) { callback.invoke(input); } } }}); } private static void forEachInputWithMatchingBase(final FacesContext context, UIComponent form, final Object base, final Callback.WithArgument<UIInput> callback) { forEachInputWithMatchingBase(context, form, base, null, callback); } private static void addCollectingValidator(UIInput input, Set<String> clientIds, Map<String, Object> properties) { input.addValidator(new CollectingValidator(clientIds, properties)); } private static void removeCollectingValidator(UIInput input) { Validator collectingValidator = null; for (Validator validator : input.getValidators()) { if (validator instanceof CollectingValidator) { collectingValidator = validator; break; } } if (collectingValidator != null) { input.removeValidator(collectingValidator); } } private static Copier getCopier(FacesContext context, String copierName) { Copier copier = null; if (!isEmpty(copierName)) { Object expressionResult = evaluateExpressionGet(context, copierName); if (expressionResult instanceof Copier) { copier = (Copier) expressionResult; } else if (expressionResult instanceof String) { copier = instance((String) expressionResult); } } if (copier == null) { copier = new MultiStrategyCopier(); } return copier; } private static Set<ConstraintViolation<?>> invalidateInputsByPropertyPathAndShowMessages(final FacesContext context, UIForm form, Object bean, Set<ConstraintViolation<?>> violations, final Set<String> clientIds) { final Set<ConstraintViolation<?>> remainingViolations = new LinkedHashSet<>(violations); for (final ConstraintViolation<?> violation : violations) { forEachInputWithMatchingBase(context, form, bean, violation.getPropertyPath().toString(), new Callback.WithArgument<UIInput>() { @Override public void invoke(UIInput input) { input.setValid(false); String clientId = input.getClientId(context); addError(clientId, violation.getMessage(), getLabel(input)); clientIds.remove(clientId); remainingViolations.remove(violation); }}); } return remainingViolations; } private static String invalidateInputsByClientIdsAndCollectLabels(final FacesContext context, UIForm form, Set<String> clientIds) { final StringBuilder labels = new StringBuilder(); if (!clientIds.isEmpty()) { forEachComponent(context).fromRoot(form).havingIds(clientIds).invoke(new Callback.WithArgument<UIInput>() { @Override public void invoke(UIInput input) { input.setValid(false); if (labels.length() > 0) { labels.append(", "); } labels.append(getLabel(input)); }}); } return labels.toString(); } private static void showMessages(FacesContext context, UIForm form, Set<ConstraintViolation<?>> violations, Set<String> clientIds, String labels, String showMessagesFor) { if ("@form".equals(showMessagesFor)) { String formId = form.getClientId(context); addErrors(formId, violations, labels); } else if ("@all".equals(showMessagesFor)) { for (String clientId : clientIds) { addErrors(clientId, violations, labels); } } else if ("@global".equals(showMessagesFor)) { for (ConstraintViolation<?> violation : violations) { addGlobalError(violation.getMessage(), labels); } } else { for (String clientId : showMessagesFor.split("\\s+")) { addErrors(clientId, violations, labels); } } } private static void addErrors(String clientId, Set<ConstraintViolation<?>> violations, String labels) { for (ConstraintViolation<?> violation : violations) { addError(clientId, violation.getMessage(), labels); } } // Nested classes ------------------------------------------------------------------------------------------------- public static final class CollectingValidator implements Validator { private final Set<String> clientIds; private final Map<String, Object> properties; public CollectingValidator(Set<String> clientIds, Map<String, Object> properties) { this.clientIds = clientIds; this.properties = properties; } @Override public void validate(FacesContext context, UIComponent component, Object value) { ValueExpression valueExpression = component.getValueExpression(VALUE_ATTRIBUTE); if (valueExpression != null) { ValueReference valueReference = getValueReference(context.getELContext(), valueExpression); clientIds.add(component.getClientId(context)); properties.put(valueReference.getProperty().toString(), value); } } } // Callbacks ------------------------------------------------------------------------------------------------------ private abstract static class ValidateBeanCallback implements Callback.Void { @Override public void invoke() { try { run(); } catch (Exception e) { // Explicitly log since exceptions in PhaseListeners will be largely swallowed and ignored by JSF runtime. logger.log(SEVERE, "Exception occured while doing validation.", e); // Set validation failed and proceed to render response. validationFailed(); renderResponse(); throw new FacesException(e); // Rethrow, but JSF runtime will do little with it. } } public abstract void run(); } }