[ 
https://issues.apache.org/jira/browse/WW-5695?focusedWorklogId=1037687&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1037687
 ]

ASF GitHub Bot logged work on WW-5695:
--------------------------------------

                Author: ASF GitHub Bot
            Created on: 25/Aug/26 06:17
            Start Date: 25/Aug/26 06:17
    Worklog Time Spent: 10m 
      Work Description: Copilot commented on code in PR #1865:
URL: https://github.com/apache/struts/pull/1865#discussion_r3850064775


##########
core/src/main/java/org/apache/struts2/components/UIBean.java:
##########
@@ -903,6 +917,69 @@ public void evaluateParams() {
         }
 
         evaluateExtraParams();
+
+        // must run after evaluateExtraParams(): that is where TextField 
resolves attributes.type,
+        // and the control type decides which constraints are legal
+        addConstraintAttributes(form);
+    }
+
+    /**
+     * Derives HTML5 constraint attributes for this field from the action's 
validators.
+     * <p>
+     * This reaches {@link Form#getFieldValidators(String)}, which resolves 
the action's validators via
+     * {@code AnnotationActionValidatorManager}, which in turn dereferences 
the current
+     * {@code ActionInvocation} unconditionally. Before this feature that path 
only ran under the opt-in
+     * {@code validate="true"}; with constraint derivation gated only by
+     * {@code struts.ui.html5.constraints}, every {@code html5}-themed form 
now runs it, including one
+     * rendered outside action scope (a direct JSP include from a plain 
servlet, say) — which would NPE.
+     * A stray {@code null} in the validator list, and a broken {@code ${}} in 
a validator message
+     * unbalancing the value stack in {@code ValidatorSupport.getMessage}, 
land in the same call. This
+     * feature is purely decorative — a missing constraint attribute costs 
nothing, a 500 costs the page —
+     * so the broad catch here is deliberate rather than a mistake.
+     *
+     * @since 7.4.0
+     */
+    protected void addConstraintAttributes(Form form) {
+        if (!html5ConstraintsEnabled || form == null || htmlConstraintProvider 
== null) {
+            return;
+        }
+        String fieldName = (String) getAttributes().get("name");
+        if (fieldName == null) {
+            return;
+        }
+        try {
+            Map<String, String> constraints = 
htmlConstraintProvider.constraintsFor(
+                form.getFieldValidators(fieldName), getControlType(), 
stack.peek());

Review Comment:
   `stack.peek()` is not necessarily the action: `ModelDrivenInterceptor` 
pushes the model on top of the ValueStack. Model-driven forms therefore resolve 
validator messages and i18n bundles against the model rather than the action 
used by server-side validation. Obtain the action from the current 
`ActionInvocation`, passing null when no invocation exists.



##########
core/src/main/java/org/apache/struts2/components/UIBean.java:
##########
@@ -903,6 +917,69 @@ public void evaluateParams() {
         }
 
         evaluateExtraParams();
+
+        // must run after evaluateExtraParams(): that is where TextField 
resolves attributes.type,
+        // and the control type decides which constraints are legal
+        addConstraintAttributes(form);
+    }
+
+    /**
+     * Derives HTML5 constraint attributes for this field from the action's 
validators.
+     * <p>
+     * This reaches {@link Form#getFieldValidators(String)}, which resolves 
the action's validators via
+     * {@code AnnotationActionValidatorManager}, which in turn dereferences 
the current
+     * {@code ActionInvocation} unconditionally. Before this feature that path 
only ran under the opt-in
+     * {@code validate="true"}; with constraint derivation gated only by
+     * {@code struts.ui.html5.constraints}, every {@code html5}-themed form 
now runs it, including one
+     * rendered outside action scope (a direct JSP include from a plain 
servlet, say) — which would NPE.
+     * A stray {@code null} in the validator list, and a broken {@code ${}} in 
a validator message
+     * unbalancing the value stack in {@code ValidatorSupport.getMessage}, 
land in the same call. This
+     * feature is purely decorative — a missing constraint attribute costs 
nothing, a 500 costs the page —
+     * so the broad catch here is deliberate rather than a mistake.
+     *
+     * @since 7.4.0
+     */
+    protected void addConstraintAttributes(Form form) {
+        if (!html5ConstraintsEnabled || form == null || htmlConstraintProvider 
== null) {
+            return;
+        }
+        String fieldName = (String) getAttributes().get("name");
+        if (fieldName == null) {
+            return;
+        }
+        try {
+            Map<String, String> constraints = 
htmlConstraintProvider.constraintsFor(
+                form.getFieldValidators(fieldName), getControlType(), 
stack.peek());
+            if (constraints.isEmpty()) {
+                return;
+            }
+            constraints = new LinkedHashMap<>(constraints);
+            constraints.keySet().removeIf(this::isAlreadyRendered);
+            if (!constraints.isEmpty()) {
+                addParameter("constraints", constraints);
+            }
+        } catch (Exception e) {
+            LOG.warn("Failed to derive HTML5 constraint attributes for field 
[{}], skipping", fieldName, e);
+        }

Review Comment:
   Catching the exception does not make this path fail-safe when 
`ValidatorSupport.getMessage` throws after pushing the action/validator: its 
pops are not in a `finally`, so subsequent rendering continues with a corrupted 
ValueStack. Capture the stack depth before derivation and restore it in a 
`finally`, or make `ValidatorSupport` guarantee balanced cleanup.



##########
core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java:
##########
@@ -0,0 +1,116 @@
+/*
+ * 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;
+
+/**
+ * Decides whether a Java regular expression can be handed to a browser as an 
HTML5 {@code pattern}
+ * attribute without changing meaning.
+ * <p>
+ * This is an allowlist by design. A denylist of Java-only constructs would 
violate the
+ * never-false-reject rule the first time it missed one, because a missed 
construct becomes a pattern
+ * the browser interprets differently and the user cannot get past. Anything 
not provably common to
+ * both engines is rejected, and the field simply gets no client-side check.
+ *
+ * @since 7.4.0
+ */
+public final class EcmaScriptSafeRegex {
+
+    /**
+     * Escapes with identical meaning in both engines.
+     * <p>
+     * {@code \s} and {@code \S} are deliberately absent. Java's {@code \s} is 
ASCII-only by default
+     * while ECMAScript's is the wider Unicode set, so {@code ^\S+$} accepts a 
value containing NBSP
+     * on the server and rejects it in the browser. {@code \d} and {@code \w} 
are safe — both engines
+     * are ASCII-only for those, and JavaScript never widens them.
+     */
+    private static final String ALLOWED_ESCAPES = 
"dDwWbBnrtf\\.*+?()[]{}|^$/-";

Review Comment:
   Java 17 treats `\b`/`\B` boundaries as Unicode-aware, while the browser's 
ECMAScript boundary is based on ASCII word characters. For example, Java 17 
accepts `^\bäiti\b$` for `äiti`, but the emitted HTML pattern rejects it, 
violating the never-false-reject rule. Remove both escapes from the safe subset 
(JDK 19 changed Java's behavior, but Struts still targets Java 17).



##########
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.

Review Comment:
   The advertised `type="email"` replacement cannot work with the current 
rendering path. `html5/text.ftl` emits `type="text"` before `constraints.ftl` 
renders the provider map, producing a duplicate `type`; HTML keeps the first 
value. Either restrict this contract to post-rendered constraint attributes or 
merge provider overrides into the normal attribute model before the template 
emits `type`.



##########
core/src/main/java/org/apache/struts2/components/UIBean.java:
##########
@@ -903,6 +917,69 @@ public void evaluateParams() {
         }
 
         evaluateExtraParams();
+
+        // must run after evaluateExtraParams(): that is where TextField 
resolves attributes.type,
+        // and the control type decides which constraints are legal
+        addConstraintAttributes(form);
+    }
+
+    /**
+     * Derives HTML5 constraint attributes for this field from the action's 
validators.
+     * <p>
+     * This reaches {@link Form#getFieldValidators(String)}, which resolves 
the action's validators via
+     * {@code AnnotationActionValidatorManager}, which in turn dereferences 
the current
+     * {@code ActionInvocation} unconditionally. Before this feature that path 
only ran under the opt-in
+     * {@code validate="true"}; with constraint derivation gated only by
+     * {@code struts.ui.html5.constraints}, every {@code html5}-themed form 
now runs it, including one
+     * rendered outside action scope (a direct JSP include from a plain 
servlet, say) — which would NPE.
+     * A stray {@code null} in the validator list, and a broken {@code ${}} in 
a validator message
+     * unbalancing the value stack in {@code ValidatorSupport.getMessage}, 
land in the same call. This
+     * feature is purely decorative — a missing constraint attribute costs 
nothing, a 500 costs the page —
+     * so the broad catch here is deliberate rather than a mistake.
+     *
+     * @since 7.4.0
+     */
+    protected void addConstraintAttributes(Form form) {
+        if (!html5ConstraintsEnabled || form == null || htmlConstraintProvider 
== null) {
+            return;
+        }
+        String fieldName = (String) getAttributes().get("name");
+        if (fieldName == null) {
+            return;
+        }
+        try {
+            Map<String, String> constraints = 
htmlConstraintProvider.constraintsFor(
+                form.getFieldValidators(fieldName), getControlType(), 
stack.peek());
+            if (constraints.isEmpty()) {
+                return;
+            }
+            constraints = new LinkedHashMap<>(constraints);
+            constraints.keySet().removeIf(this::isAlreadyRendered);
+            if (!constraints.isEmpty()) {
+                addParameter("constraints", constraints);
+            }
+        } catch (Exception e) {
+            LOG.warn("Failed to derive HTML5 constraint attributes for field 
[{}], skipping", fieldName, e);
+        }
+    }
+
+    /**
+     * True when the developer already supplied this attribute explicitly — as 
a declared tag attribute
+     * (e.g. {@code maxlength}) or a dynamic one (e.g. {@code min} on a 
numeric textfield, which is not a
+     * declared attribute of any component) — so a derived constraint of the 
same name must not be
+     * rendered a second time. The developer's own value always wins.
+     * <p>
+     * {@code required} is deliberately excluded from the declared-attribute 
half of this check:
+     * {@code requiredLabel} stores an unrelated boolean under the same {@code 
attributes.required} key,
+     * purely to draw a label asterisk in the xhtml theme, and that must never 
suppress a genuine
+     * {@code required} constraint derived from a {@code required}/{@code 
requiredstring} validator. A
+     * {@code required} attribute the developer typed by hand as a dynamic 
attribute still wins.
+     */
+    private boolean isAlreadyRendered(String attributeName) {
+        if (dynamicAttributes.containsKey(attributeName)) {
+            return true;
+        }
+        return !"required".equals(attributeName) && 
getAttributes().containsKey(attributeName);
     }

Review Comment:
   HTML attribute names are ASCII case-insensitive, but this exact-key check 
misses dynamic attributes such as `MAXLENGTH` or `Min`. Because derived 
attributes render before dynamic ones, the browser keeps the derived duplicate 
and the developer's value does not win as documented. Compare dynamic keys 
case-insensitively.



##########
core/src/main/java/org/apache/struts2/components/HtmlControlType.java:
##########
@@ -0,0 +1,78 @@
+/*
+ * 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 java.util.EnumSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * The kind of HTML form control a {@link UIBean} renders, used to decide 
which HTML5 constraint
+ * attributes are legal on it.
+ * <p>
+ * This models the <em>control</em> rather than the {@code type} attribute, 
because {@code textarea}
+ * and {@code select} have no {@code type} attribute yet still accept {@code 
required}.
+ *
+ * @since 7.4.0
+ */
+public enum HtmlControlType {
+
+    TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL,
+    NUMBER, RANGE,
+    DATE, MONTH, WEEK, TIME, DATETIME_LOCAL,
+    CHECKBOX, RADIO, FILE, HIDDEN, SELECT,

Review Comment:
   `CHECKBOX` and `HIDDEN` are public control kinds, but the built-in 
`Checkbox` and `Hidden` components inherit `OTHER`; only the six new overrides 
ever expose concrete types. A replacement provider therefore cannot distinguish 
these controls, undermining the swappable mapping policy. Return their actual 
control types and keep the conservative behavior in 
`StrutsHtmlConstraintProvider`.



##########
core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java:
##########
@@ -0,0 +1,208 @@
+/*
+ * 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.CreditCardValidator;
+import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
+import org.apache.struts2.validator.validators.EmailValidator;
+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 {
+
+    /**
+     * The HTML5 boolean attribute; its canonical serialisation repeats the 
attribute name as the value.
+     */
+    private static final String REQUIRED = "required";
+
+    @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 RequiredStringValidator) {
+            addRequiredString(attributes, control);
+        } else if (validator instanceof RequiredFieldValidator) {
+            addRequiredField(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);
+        }
+    }
+
+    /**
+     * {@code requiredstring} fails on null, empty and (by default) blank, so 
the browser's
+     * {@code required} can only reject what the server would also reject. 
Safe on any text-entry control.
+     */
+    protected void addRequiredString(Map<String, String> attributes, 
HtmlControlType control) {
+        if (!control.supportsLength()) {
+            return;
+        }
+        attributes.put(REQUIRED, REQUIRED);
+    }
+
+    /**
+     * {@code required} fails only on null, an empty array or an empty 
collection. A control that submits
+     * an empty string rather than omitting the parameter therefore passes 
server-side while the browser
+     * blocks it — an empty text input, a select with an empty-valued header 
option, and an unticked
+     * checkbox (CheckboxInterceptor substitutes "false") are all in that 
group. Only RADIO and FILE omit
+     * the parameter entirely when empty, so only they agree with the browser.
+     */
+    protected void addRequiredField(Map<String, String> attributes, 
HtmlControlType control) {
+        if (control != HtmlControlType.RADIO && control != 
HtmlControlType.FILE) {
+            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;
+        }
+        // trim defaults to true, and the server matches the trimmed value 
while pattern matches the
+        // raw one: "[a-z]+" would accept "abc " server-side and be blocked by 
the browser
+        if (validator.isTrimed()) {
+            return;
+        }
+        // Both extend RegexFieldValidator but do not match their regex 
against the raw value:
+        // CreditCardValidator strips all whitespace first, and both carry 
grammars the browser
+        // does not share. Neither is expressible as a pattern.
+        if (validator instanceof EmailValidator || validator instanceof 
CreditCardValidator) {
+            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 (!isNumericRange(control)) {
+            // 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;
+        }
+        // min is guarded by isIntegral; see the comment on that method. The 
shipped Integer/Short/Long
+        // range validators always pass it, but a custom 
RangeValidatorSupport<Double> would not.
+        Object min = validator.getMin();
+        if (isIntegral(min)) {
+            putIfPresent(attributes, "min", min);
+        }
+        putIfPresent(attributes, "max", validator.getMax());
+    }
+
+    protected void addDoubleRange(Map<String, String> attributes, 
DoubleRangeFieldValidator validator, HtmlControlType control) {
+        if (!isNumericRange(control)) {
+            // 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
+        Double minInclusive = validator.getMinInclusive();
+        if (isIntegral(minInclusive)) {
+            putIfPresent(attributes, "min", minInclusive);
+        }
+        putIfPresent(attributes, "max", validator.getMaxInclusive());
+    }
+
+    private boolean isNumericRange(HtmlControlType control) {
+        return control.supportsRange() && (control == HtmlControlType.NUMBER 
|| control == HtmlControlType.RANGE);
+    }
+
+    /**
+     * A fractional {@code min} moves the HTML step base off zero, and with 
the default {@code step="1"}
+     * the browser then rejects whole numbers the server accepts. {@code max} 
does not participate in the
+     * step base, so only {@code min} needs this guard.
+     */
+    private boolean isIntegral(Object value) {
+        if (!(value instanceof java.lang.Number number)) {
+            return false;
+        }
+        double asDouble = number.doubleValue();
+        return !Double.isNaN(asDouble) && !Double.isInfinite(asDouble) && 
asDouble == Math.floor(asDouble);
+    }

Review Comment:
   Converting arbitrary `Number` values to `double` can round a fractional 
custom bound to an integer. A `BigDecimal("1.0000000000000000001")` passes this 
check, then renders that fractional value as `min`, shifts the HTML step base, 
and can reject values accepted by the server. Determine integrality from the 
decimal representation instead.





Issue Time Tracking
-------------------

    Worklog Id:     (was: 1037687)
    Time Spent: 40m  (was: 0.5h)

> Derive HTML5 constraint attributes from validators in the html5 theme
> ---------------------------------------------------------------------
>
>                 Key: WW-5695
>                 URL: https://issues.apache.org/jira/browse/WW-5695
>             Project: Struts 2
>          Issue Type: New Feature
>            Reporter: Lukasz Lenart
>            Priority: Major
>             Fix For: 7.4.0
>
>          Time Spent: 40m
>  Remaining Estimate: 0h
>
> h2. Goal
> Let the {{html5}} theme emit native HTML5 constraint attributes 
> ({{required}}, {{minlength}}, {{maxlength}}, {{pattern}}, {{min}}, {{max}}) 
> derived from the action's validators, as the successor to the JavaScript 
> client-side validator deprecated in WW-5694.
> Because constraints ride on each individual input rather than on a generated 
> per-form function, this also dissolves the root cause of WW-2975 instead of 
> patching it — there is no central field list for a field to be missing from.
> h2. The governing rule: never false-reject
> A constraint is emitted only when the browser cannot reject something the 
> server would accept. This rules out more than it first appears:
> {{min}} and {{max}} are inert on {{type="text"}} — they only apply to 
> {{number}}, {{range}} and the temporal types. Honouring an int or double 
> validator therefore means switching the input to {{type="number"}}, and that 
> _is_ a false rejection: a browser {{type="number"}} refuses {{1234,50}}, 
> which the framework's locale-aware conversion accepts in a comma-decimal 
> locale. The same argument rules out {{type="email"}} and {{type="url"}}, 
> whose browser regexes diverge from {{EmailValidator}} and {{URLValidator}} — 
> see WW-4395, still open, for the email side of that divergence.
> Hence the rule: _Struts never sets or changes an input's type. It only adds 
> constraints that are safe for whatever type is already there._ A developer 
> who writes {{type="number"}} has accepted that widget's semantics, so {{min}} 
> and {{max}} become pure additions.
> h2. Mapping
> || Validator || Emits || Condition ||
> | required | required | always |
> | requiredstring | required | always; the server is stricter on 
> whitespace-only input, which is safe |
> | stringlength | minlength / maxlength | only when {{trim="false"}} — the 
> server measures the trimmed value, so a maxlength derived from a trimming 
> validator would stop the user typing input the server would accept |
> | regex | pattern | only when {{caseSensitive="true"}} and the regex is 
> ECMAScript-safe |
> | int, short, long | min / max | only when the control is already numeric |
> | double | min / max | only when the control is already numeric |
> | date | min / max | only when the control is already temporal |
> | email, url | nothing | browser regexes diverge from the framework's |
> | creditcard, fieldexpression, expression, conversion, visitor | nothing | no 
> safe mapping |
> {{RegexFieldValidator}} uses {{matcher.matches()}}, so it is fully anchored 
> and matches HTML5 {{pattern}} semantics. The divergence is syntactic, not 
> positional — Java-only constructs such as POSIX classes, possessive 
> quantifiers and {{\\A}} / {{\\z}} are the hazard, and 
> {{caseSensitive="false"}} has no {{pattern}} equivalent because HTML allows 
> no regex flags.
> ECMAScript-safety detection must be an _allowlist_, not a denylist: a 
> denylist violates the rule the first time it misses a construct. Allow 
> literals, {{\\d}} {{\\w}} {{\\s}} and their negations, character classes 
> without POSIX or Unicode property syntax, grouping, alternation, anchors and 
> bounded quantifiers; emit no {{pattern}} for anything else. This is 
> deliberately strict and is the piece most likely to need tuning after real 
> use.
> h2. Design
> * New constant {{struts.ui.html5.constraints}}, default {{false}} in 7.4.0. 
> The {{html5}} theme shipped in 7.2.0, so emitting {{required}} on upgrade 
> would start blocking submits on forms that render unchanged today. The 
> default flips to {{true}} in 8.0.0.
> * New enum {{HtmlControlType}} — the provider's real question is which 
> constraint attributes are legal on a control, not what string is in the type 
> attribute. It models the control rather than the attribute, because textarea 
> and select have no type attribute yet do accept {{required}}. Members cover 
> the HTML5 input types plus TEXTAREA, SELECT and OTHER, with predicates 
> {{supportsPattern()}}, {{supportsLength()}} and {{supportsRange()}}. Its 
> {{from(String)}} factory must never throw: the type attribute is 
> OGNL-evaluated, so at runtime it can be any string, and unknown values 
> normalise to OTHER, which supports nothing — the conservative default falls 
> out for free.
> * New interface {{HtmlConstraintProvider}} with default implementation 
> {{StrutsHtmlConstraintProvider}}, taking the field's validators and an 
> {{HtmlControlType}} and returning a map of attribute name to value. 
> Registered once in {{struts-beans.xml}} following the {{UrlRenderer}} model — 
> registering a bean under two types builds two instances. Because the default 
> policy is deliberately restrictive, the swappable bean is how applications 
> wanting best-effort mapping get served.
> * {{UIBean.evaluateParams}} already resolves the {{Form}} ancestor to 
> populate {{tagNames}}; that is the hook. Gate the computation on the constant 
> so the cost is zero when off.
> * New {{Form.getFieldValidators(String)}} that resolves the action's 
> validator list once and memoises it on the form's attributes, then filters 
> per call. The existing {{getValidators(String)}} re-runs the action-mapping 
> lookup on every call, so a twenty-field form would do twenty full lookups.
> * New {{html5/constraints.ftl}}, included from {{common-attributes.ftl}} so 
> every html5 input picks it up without per-template edits.
> h2. Messages
> The provider returns the full attribute set, not only constraints. Validator 
> messages ride the same map as {{data-msg-}} entries keyed by validator type — 
> {{data-msg-required}}, {{data-msg-stringlength}} — resolved via 
> {{validator.getMessage(action)}}, which goes through 
> {{DelegatingValidatorContext}} and {{textProviderFactory}} and is therefore 
> properly i18n'd.
> A message is emitted for every validator that has one, _including_ those 
> producing no constraint. An email validator therefore contributes 
> {{data-msg-email}} and nothing else, which is exactly where an application 
> most needs it. Struts ships nothing that consumes these attributes — no 
> JavaScript.
> h2. Do not conflate requiredLabel
> {{requiredLabel}} keeps meaning "draw an asterisk next to the label". It must 
> never produce a {{required}} attribute — only a {{required}} validator does. 
> This is the most likely regression in this work and needs an explicit test.
> h2. Testing
> The negative cases carry the weight, because they are what protects the rule: 
> {{stringlength trim="true"}} emits no length constraints; {{regex 
> caseSensitive="false"}} emits no pattern; a Java-only regex emits no pattern; 
> int and double on a text control emit no range; email and url never set or 
> change the type; {{HtmlControlType.from}} never throws on null or unknown 
> input.
> Note the harness trap: a form-validation tag test needs {{initDispatcher}} 
> with {{TestConfigurationProvider}} _and_ {{createMocks()}} in setUp, plus a 
> {{prepareMockInvocation()}} EasyMock helper. Without them 
> {{evaluateClientSideJsEnablement}} finds no {{ValidationInterceptor}}, 
> {{performValidation}} stays false, and no validation function is emitted at 
> all — so the test passes or fails for entirely the wrong reason.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to