This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch feature/WW-5695-html5-constraint-validation in repository https://gitbox.apache.org/repos/asf/struts.git
commit e9b8fb44c01c87e6b80fa575586fae250e489a05 Author: Lukasz Lenart <[email protected]> AuthorDate: Mon Aug 24 20:32:33 2026 +0200 WW-5695 feat(components): map validators onto HTML5 constraint attributes Adds HtmlConstraintProvider and its conservative default implementation, which never sets or changes an input's type: type=number would reject 1234,50 that locale-aware conversion accepts, and browser email/url grammars differ from the framework's validators. Range constraints therefore land only on a control the developer already made numeric. Registered as a swappable bean so applications wanting a best-effort mapping can replace the policy. Co-Authored-By: Claude Opus 5 <[email protected]> --- .../struts2/components/HtmlConstraintProvider.java | 44 +++++ .../components/StrutsHtmlConstraintProvider.java | 151 +++++++++++++++++ core/src/main/resources/struts-beans.xml | 2 + .../StrutsHtmlConstraintProviderTest.java | 184 +++++++++++++++++++++ 4 files changed, 381 insertions(+) diff --git a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java new file mode 100644 index 000000000..6fdeaa52b --- /dev/null +++ b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.components; + +import org.apache.struts2.validator.Validator; + +import java.util.List; +import java.util.Map; + +/** + * Maps a field's validators onto the HTML attributes a theme should render for it. + * <p> + * The default implementation is deliberately conservative — see {@link StrutsHtmlConstraintProvider}. + * Applications wanting a best-effort mapping (an {@code email} validator becoming + * {@code type="email"}, say) should register their own implementation instead. + * + * @since 7.4.0 + */ +public interface HtmlConstraintProvider { + + /** + * @param validators the field's validators; may be null or empty + * @param control the kind of control being rendered + * @param action the action instance, used to resolve i18n validator messages; may be null + * @return attribute name to value; never null, possibly empty + */ + Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action); +} diff --git a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java new file mode 100644 index 000000000..c4f5e151e --- /dev/null +++ b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.components; + +import org.apache.struts2.validator.Validator; +import org.apache.struts2.validator.validators.DoubleRangeFieldValidator; +import org.apache.struts2.validator.validators.RangeValidatorSupport; +import org.apache.struts2.validator.validators.RegexFieldValidator; +import org.apache.struts2.validator.validators.RequiredFieldValidator; +import org.apache.struts2.validator.validators.RequiredStringValidator; +import org.apache.struts2.validator.validators.StringLengthFieldValidator; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Default {@link HtmlConstraintProvider}. + * <p> + * Governed by one rule: never false-reject. A constraint is emitted only when the browser cannot + * reject input the server would accept. In particular this implementation <em>never sets or changes + * an input's {@code type}</em> — switching a field to {@code type="number"} would reject + * {@code 1234,50}, which the framework's locale-aware conversion accepts in a comma-decimal locale, + * and the browsers' {@code email}/{@code url} grammars differ from the framework's validators. + * Range constraints are therefore emitted only on a control the developer already made numeric. + * + * @since 7.4.0 + */ +public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { + + @Override + public Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action) { + Map<String, String> attributes = new LinkedHashMap<>(); + if (validators == null || validators.isEmpty() || control == null) { + return attributes; + } + for (Validator validator : validators) { + addConstraints(attributes, validator, control); + addMessage(attributes, validator, action); + } + return attributes; + } + + protected void addConstraints(Map<String, String> attributes, Validator validator, HtmlControlType control) { + if (validator instanceof RequiredFieldValidator || validator instanceof RequiredStringValidator) { + addRequired(attributes, control); + } else if (validator instanceof StringLengthFieldValidator lengthValidator) { + addLength(attributes, lengthValidator, control); + } else if (validator instanceof RegexFieldValidator regexValidator) { + addPattern(attributes, regexValidator, control); + } else if (validator instanceof DoubleRangeFieldValidator doubleValidator) { + addDoubleRange(attributes, doubleValidator, control); + } else if (validator instanceof RangeValidatorSupport<?> rangeValidator) { + addRange(attributes, rangeValidator, control); + } + } + + protected void addRequired(Map<String, String> attributes, HtmlControlType control) { + if (control == HtmlControlType.OTHER) { + return; + } + attributes.put("required", "required"); + } + + protected void addLength(Map<String, String> attributes, StringLengthFieldValidator validator, HtmlControlType control) { + // with trim=true the server measures the trimmed value, so a maxlength taken from it would + // stop the user typing input the server would have accepted + if (!control.supportsLength() || validator.isTrim()) { + return; + } + if (validator.getMinLength() > -1) { + attributes.put("minlength", String.valueOf(validator.getMinLength())); + } + if (validator.getMaxLength() > -1) { + attributes.put("maxlength", String.valueOf(validator.getMaxLength())); + } + } + + protected void addPattern(Map<String, String> attributes, RegexFieldValidator validator, HtmlControlType control) { + // HTML pattern accepts no flags, so a case-insensitive rule cannot be expressed at all + if (!control.supportsPattern() || !validator.isCaseSensitive()) { + return; + } + String regex = validator.getRegex(); + if (EcmaScriptSafeRegex.isSafe(regex)) { + attributes.put("pattern", regex); + } + } + + protected void addRange(Map<String, String> attributes, RangeValidatorSupport<?> validator, HtmlControlType control) { + if (!control.supportsRange()) { + return; + } + if (control != HtmlControlType.NUMBER && control != HtmlControlType.RANGE) { + // Temporal controls support ranges too, but min/max there need per-control ISO + // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> yyyy-'W'ww, time -> HH:mm). + // Deliberately deferred; DateRangeFieldValidator therefore emits nothing for now. + return; + } + putIfPresent(attributes, "min", validator.getMin()); + putIfPresent(attributes, "max", validator.getMax()); + } + + protected void addDoubleRange(Map<String, String> attributes, DoubleRangeFieldValidator validator, HtmlControlType control) { + if (!control.supportsRange()) { + return; + } + if (control != HtmlControlType.NUMBER && control != HtmlControlType.RANGE) { + // Temporal controls support ranges too, but min/max there need per-control ISO + // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> yyyy-'W'ww, time -> HH:mm). + // Deliberately deferred; DateRangeFieldValidator therefore emits nothing for now. + return; + } + // exclusive bounds have no HTML equivalent; omitting them leaves the browser more + // permissive than the server, which is the safe direction + putIfPresent(attributes, "min", validator.getMinInclusive()); + putIfPresent(attributes, "max", validator.getMaxInclusive()); + } + + protected void addMessage(Map<String, String> attributes, Validator validator, Object action) { + if (action == null) { + return; + } + String message = validator.getMessage(action); + if (message != null && !message.isEmpty()) { + attributes.put("data-msg-" + validator.getValidatorType(), message); + } + } + + private void putIfPresent(Map<String, String> attributes, String name, Object value) { + if (value != null) { + attributes.put(name, String.valueOf(value)); + } + } +} diff --git a/core/src/main/resources/struts-beans.xml b/core/src/main/resources/struts-beans.xml index 84f0919dc..8ad4dff8b 100644 --- a/core/src/main/resources/struts-beans.xml +++ b/core/src/main/resources/struts-beans.xml @@ -145,6 +145,8 @@ <bean type="org.apache.struts2.components.UrlRenderer" name="struts" class="org.apache.struts2.components.ServletUrlRenderer"/> + <bean type="org.apache.struts2.components.HtmlConstraintProvider" name="struts" + class="org.apache.struts2.components.StrutsHtmlConstraintProvider"/> <bean type="org.apache.struts2.views.util.UrlHelper" name="struts" class="org.apache.struts2.views.util.DefaultUrlHelper"/> diff --git a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java new file mode 100644 index 000000000..f6d965c2b --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.components; + +import org.apache.struts2.ActionSupport; +import org.apache.struts2.validator.Validator; +import org.apache.struts2.validator.validators.DoubleRangeFieldValidator; +import org.apache.struts2.validator.validators.EmailValidator; +import org.apache.struts2.validator.validators.IntRangeFieldValidator; +import org.apache.struts2.validator.validators.RegexFieldValidator; +import org.apache.struts2.validator.validators.RequiredFieldValidator; +import org.apache.struts2.validator.validators.RequiredStringValidator; +import org.apache.struts2.validator.validators.StringLengthFieldValidator; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StrutsHtmlConstraintProviderTest { + + private StrutsHtmlConstraintProvider provider; + private Object action; + + @Before + public void setUp() { + provider = new StrutsHtmlConstraintProvider(); + action = new ActionSupport(); + } + + private Map<String, String> constraints(Validator validator, HtmlControlType control) { + return provider.constraintsFor(singletonList(validator), control, null); + } + + @Test + public void requiredValidatorEmitsRequired() { + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.TEXT)) + .containsEntry("required", "required"); + } + + @Test + public void requiredStringEmitsRequiredEvenThoughServerIsStricter() { + assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXT)) + .containsEntry("required", "required"); + } + + @Test + public void stringLengthEmitsLengthsWhenNotTrimming() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(false); + validator.setMinLength(3); + validator.setMaxLength(10); + + assertThat(constraints(validator, HtmlControlType.TEXT)) + .containsEntry("minlength", "3") + .containsEntry("maxlength", "10"); + } + + @Test + public void stringLengthEmitsNothingWhenTrimming() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(true); + validator.setMinLength(3); + validator.setMaxLength(10); + + // the server measures the trimmed value, so maxlength here would stop the user + // typing input the server would have accepted + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void stringLengthEmitsNothingOnAControlWithoutLength() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(false); + validator.setMaxLength(10); + + assertThat(constraints(validator, HtmlControlType.NUMBER)).isEmpty(); + } + + @Test + public void regexEmitsPatternWhenPortableAndCaseSensitive() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("[a-z]+"); + validator.setCaseSensitive(true); + + assertThat(constraints(validator, HtmlControlType.TEXT)) + .containsEntry("pattern", "[a-z]+"); + } + + @Test + public void regexEmitsNothingWhenCaseInsensitive() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("[a-z]+"); + validator.setCaseSensitive(false); + + // HTML pattern accepts no flags, so a case-insensitive rule cannot be expressed + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void regexEmitsNothingWhenNotPortable() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("\\p{Alpha}+"); + validator.setCaseSensitive(true); + + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void intRangeEmitsBoundsOnlyOnANumericControl() { + IntRangeFieldValidator validator = new IntRangeFieldValidator(); + validator.setMin(5); + validator.setMax(50); + + assertThat(constraints(validator, HtmlControlType.NUMBER)) + .containsEntry("min", "5") + .containsEntry("max", "50"); + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void doubleRangeEmitsInclusiveBoundsOnlyOnANumericControl() { + DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator(); + validator.setMinInclusive(6000.1); + validator.setMaxInclusive(10000.1); + + assertThat(constraints(validator, HtmlControlType.NUMBER)) + .containsEntry("min", "6000.1") + .containsEntry("max", "10000.1"); + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void emailValidatorNeverContributesAConstraint() { + // the browser's email grammar differs from EmailValidator's, so honouring it + // could reject an address the server accepts + assertThat(constraints(new EmailValidator(), HtmlControlType.TEXT)).isEmpty(); + assertThat(constraints(new EmailValidator(), HtmlControlType.EMAIL)).isEmpty(); + } + + @Test + public void unknownControlGetsNothing() { + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.OTHER)).isEmpty(); + } + + @Test + public void emptyInputIsHandled() { + assertThat(provider.constraintsFor(null, HtmlControlType.TEXT, null)).isEmpty(); + assertThat(provider.constraintsFor(List.of(), HtmlControlType.TEXT, null)).isEmpty(); + } + + @Test + public void messageIsEmittedEvenForAValidatorThatContributesNoConstraint() { + Validator validator = mock(Validator.class); + when(validator.getValidatorType()).thenReturn("email"); + when(validator.getMessage(action)).thenReturn("not an email"); + + Map<String, String> result = + provider.constraintsFor(singletonList(validator), HtmlControlType.TEXT, action); + + assertThat(result).containsEntry("data-msg-email", "not an email"); + } +}
