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");
+    }
+}

Reply via email to