Saturday, September 12, 2020

OmniFaces 3.8 o:validateBean improvements and a handful new utilities

OmniFaces 3.8 has been released!

In this version, the <o:validateBean> has been improved to support validating nested properties annotated with @Valid. Previously it only supported validating bean's own properties. Further a handful of new utilities have been added: Beans#isProxy(Object), Beans#unwrapIfNecessary(Object), Components#getExpectedType(ValueExpression), Components#getExpectedValueType(UIComponent), Messages#asConverterException(), Messages#asValidatorException() and #{of:encodeBase64(String)}.

You can find the complete list of additions, changes and fixes at What's new in OmniFaces 3.8.1? list in showcase.

Installation

Non-Maven users: download OmniFaces 3.8.1 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users: use <version>3.8.1</version>.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnifaces</artifactId>
    <version>3.8.1</version>
</dependency>

If you're already on Jakarta EE 9 (e.g. GlassFish 6), then use 4.0-M3 instead. It's the Jakartified version of 3.8.1.

Validating nested properties

The <o:validateBean> now finally supports validating nested properties which are annotated with the @javax.validation.Valid cascade. Previously this was not supported because nested properties were not automatically copied into the shadow bean, and the violation messages were not associatable with the input components. Both issues have been resolved with help of Andre Wachsmuth.

Here's an example with an imagined Setting model:

@Named
@ViewScoped
public class Bean implements Serializable {

    @Valid
    private List<Setting> settings;

    @Inject
    private SettingService settingService;

    @PostConstruct
    public void init() {
        settings = settingService.list();
    }

    public void save() {
        settingService.save(settings);
    }

    public List<Setting> getSettings() {
        return settings;
    }
}
@ValidSetting
public class Setting {

    public enum Type {
        STRING, BOOLEAN, NUMBER, DECIMAL;
    }

    @NotNull
    private String name;

    @NotNull
    private Type type;

    @NotNull
    private String value;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Type getType() {
        return type;
    }

    public void setType(Type type) {
        this.type = type;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }

    public Boolean getAsBoolean() {
        return type == Type.BOOLEAN ? Boolean.parseBoolean(getValue()) : null;
    }

    public Long getAsNumber() {
        return type == Type.NUMBER ? Long.valueOf(getValue()) : null;
    }

    public BigDecimal getAsDecimal() {
        return type == Type.DECIMAL ? new BigDecimal(getValue()) : null;
    }
}
@Constraint(validatedBy = SettingValidator.class)
@Documented
@Target({ TYPE, METHOD, FIELD })
@Retention(RUNTIME)
public @interface ValidSetting {
    String message() default "Invalid setting value";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
public class SettingValidator implements ConstraintValidator<ValidSetting, Setting>{

    @Override
    public boolean isValid(Setting setting, ConstraintValidatorContext context) {
        if (setting == null) {
            return true; // Let @NotNull handle it.
        }
        else {
            return isValid(setting);
        }
    }

    public static boolean isValid(Setting setting) {
        switch (setting.getType()) {
            case STRING:
                return true;

            case BOOLEAN:
                return setting.getValue().matches("(true|false)");

            case NUMBER:
                try {
                    setting.getAsNumber();
                    return true;
                }
                catch (NumberFormatException e) {
                    return false;
                }

            case DECIMAL:
                try {
                    setting.getAsDecimal();
                    return true;
                }
                catch (NumberFormatException e) {
                    return false;
                }

            default:
                return false;
        }
    }
}
<h:form>
    <h:dataTable value="#{bean.settings}" var="setting">
        <h:column>
            <f:facet name="header">Name</f:facet>
            #{setting.name}
        </h:column>
        <h:column>
            <f:facet name="header">Type</f:facet>
            #{setting.type}
            <h:inputHidden id="type" value="#{setting.type}" />
        </h:column>
        <h:column>
            <f:facet name="header">Value</f:facet>
            <h:inputText id="value" value="#{setting.value}" />
            <h:message id="value_m" for="value" />
        </h:column>
    </h:dataTable>
    
    <o:validateBean value="#{bean}" showMessageFor="@violating" />
    
    <h:commandButton value="Save" action="#{bean.save}">
        <f:ajax execute="@form" render="@messages" />
    </h:commandButton>
</h:form>

In this use case, the <o:validateBean> will cascade into @Valid and execute SettingValidator for each setting. The showMessageFor="@violating" will associate each message with the violating input, which shall be the <h:inputText id="value">.

Note that the render="@messages" is also specific to OmniFaces, it's handled by the MessagesKeywordResolver, just in case you didn't knew ;)

Generic converter for @Param

The @Param has also been improved to pass the expected type into the UIComponent passed around any associated validators/converters. In case of so-called "generic entity converters" this now allows you to inspect the expected type as well. This has always worked fine for <f|o:viewParam>, but it did previously not work with @Param.

For example, this <f|o:viewParam> approach:

<f:viewParam name="id" value="#{bean.product}" />
private Product product; // +getter +setter
public class Product extends BaseEntity {
    // ...
}
@FacesConverter(forClass = BaseEntity.class)
public class BaseEntityConverter implements Converter<BaseEntity> {

    @Inject
    private BaseEntityService service;
    
    @Override
    public String getAsString(FacesContext context, UIComponent component, BaseEntity modelValue) {
        return modelValue == null ? "" : modelValue.getId().toString();
    }

    @Override
    public BaseEntity getAsObject(FacesContext context, UIComponent component, String submittedValue) {
        if (submittedValue == null) {
            return null;
        }

        try {
            Class<? extends BaseEntity> type = Components.getExpectedValueType(component);
            Long id = Long.valueOf(submittedValue);
            return service.find(type, id);
        }
        catch (Exception e) {
            throw Messages.asConverterException("Cannot convert because it threw " + e);
        }
    }
}

Will now also work fine when using @Param instead of <f|o:viewParam>.

@Param(name = "id")
private Product product; // no getter/setter

How about OmniFaces 2.x and 1.1x?

The 2.x got the same bugfixes as 3.8.1 and has been released as 2.7.8. This version is for JSF 2.2 users with CDI. In case you've already migrated to JSF 2.3, use 3.x instead.

The 1.1x is basically already since 2.5 in maintenance mode. I.e. only critical bugfix versions will be released. It's currently still at 1.14.1 (May 2017), featuring the same features as OmniFaces 2.4, but without any JSF 2.2 and CDI things and therefore compatible with CDI-less JSF 2.0/2.1.

No comments: