diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 9c12452489..ffc135f0b3 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -203,6 +203,14 @@ public final class StrutsConstants {
*/
public static final String STRUTS_UI_STATIC_CONTENT_PATH = "struts.ui.staticContentPath";
+ /**
+ * Whether the html5 theme emits HTML5 constraint attributes derived from the action's validators.
+ * Defaults to {@code false}; the default is expected to flip in a future major release.
+ *
+ * @since 7.4.0
+ */
+ public static final String STRUTS_UI_HTML5_CONSTRAINTS = "struts.ui.html5.constraints";
+
/**
* Whether WebJars support is enabled (serving and URL building)
*/
@@ -218,6 +226,14 @@ public final class StrutsConstants {
*/
public static final String STRUTS_UI_ESCAPE_HTML_BODY = "struts.ui.escapeHtmlBody";
+ /**
+ * The {@link org.apache.struts2.components.HtmlConstraintProvider} implementation used to derive
+ * HTML5 constraint attributes from an action's validators.
+ *
+ * @since 7.4.0
+ */
+ public static final String STRUTS_HTML_CONSTRAINT_PROVIDER = "struts.htmlConstraintProvider";
+
/**
* The maximum size of a multipart request (file upload)
*/
diff --git a/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java
new file mode 100644
index 0000000000..f334143935
--- /dev/null
+++ b/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.
+ *
+ * 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.
+ *
+ * {@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\\.*+?()[]{}|^$/-";
+
+ private EcmaScriptSafeRegex() {
+ }
+
+ public static boolean isSafe(String regex) {
+ if (regex == null || regex.isEmpty()) {
+ return false;
+ }
+ boolean inCharClass = false;
+ int i = 0;
+ while (i < regex.length()) {
+ char current = regex.charAt(i);
+ if (!isPortable(regex, i, current, inCharClass)) {
+ return false;
+ }
+ if (current == '[') {
+ inCharClass = true;
+ } else if (current == ']') {
+ inCharClass = false;
+ }
+ // an escape consumes the character it escapes, which must not be scanned again
+ i += (current == '\\') ? 2 : 1;
+ }
+ return !inCharClass;
+ }
+
+ /**
+ * Whether the construct starting at {@code index} means the same thing to both engines. This is
+ * the whole allowlist: anything that reaches {@code default} is a character with no special
+ * meaning in either engine, or one whose meaning is shared.
+ */
+ private static boolean isPortable(String regex, int index, char current, boolean inCharClass) {
+ switch (current) {
+ case '\\':
+ return isAllowedEscape(regex, index);
+ case '[':
+ // Java allows nested classes and POSIX names; ECMAScript allows neither
+ return !inCharClass && !regex.startsWith("[:", index);
+ case '&':
+ // Java character-class intersection
+ return !inCharClass || !isFollowedBy(regex, index, '&');
+ case '(':
+ return isPortableGroup(regex, index);
+ case '*', '+', '?', '}':
+ // possessive quantifier
+ return !isFollowedBy(regex, index, '+');
+ default:
+ return true;
+ }
+ }
+
+ private static boolean isAllowedEscape(String regex, int index) {
+ return index + 1 < regex.length() && ALLOWED_ESCAPES.indexOf(regex.charAt(index + 1)) >= 0;
+ }
+
+ /**
+ * Only non-capturing groups and lookahead are portable; named groups, lookbehind, atomic groups
+ * and inline flags are not. A plain capturing group is always fine.
+ */
+ private static boolean isPortableGroup(String regex, int index) {
+ if (!isFollowedBy(regex, index, '?')) {
+ return true;
+ }
+ if (index + 2 >= regex.length()) {
+ return false;
+ }
+ char kind = regex.charAt(index + 2);
+ return kind == ':' || kind == '=' || kind == '!';
+ }
+
+ private static boolean isFollowedBy(String regex, int index, char expected) {
+ return index + 1 < regex.length() && regex.charAt(index + 1) == expected;
+ }
+}
diff --git a/core/src/main/java/org/apache/struts2/components/File.java b/core/src/main/java/org/apache/struts2/components/File.java
index e9317afba4..58bfe42ce1 100644
--- a/core/src/main/java/org/apache/struts2/components/File.java
+++ b/core/src/main/java/org/apache/struts2/components/File.java
@@ -62,6 +62,11 @@ protected String getDefaultTemplate() {
return TEMPLATE;
}
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.FILE;
+ }
+
public void evaluateParams() {
super.evaluateParams();
diff --git a/core/src/main/java/org/apache/struts2/components/Form.java b/core/src/main/java/org/apache/struts2/components/Form.java
index 7db1477c32..cb7a4f0ab9 100644
--- a/core/src/main/java/org/apache/struts2/components/Form.java
+++ b/core/src/main/java/org/apache/struts2/components/Form.java
@@ -77,6 +77,9 @@
*
*
*
+ * The client-side JS validate attribute is deprecated since 7.4.0 — use the html5 theme's
+ * constraint attributes instead. Removed in 8.0.0.
+ *
*
*
*
Examples
@@ -98,6 +101,8 @@ public class Form extends ClosingUIBean {
public static final String OPEN_TEMPLATE = "form";
public static final String TEMPLATE = "form-close";
+ private static final String ATTR_ACTION_CLASS = "actionClass";
+
private int sequence = 0;
protected String onsubmit;
@@ -119,6 +124,10 @@ public class Form extends ClosingUIBean {
protected UrlRenderer urlRenderer;
protected ActionValidatorManager actionValidatorManager;
+ private List cachedActionValidators;
+ private String cachedActionName;
+ private boolean actionValidatorsResolved;
+
public Form(ValueStack stack, HttpServletRequest request, HttpServletResponse response) {
super(stack, request, response);
}
@@ -238,7 +247,12 @@ protected void populateComponentHtmlId(Form form) {
* @param actionName the actioName to check for
* @param namespace the namespace to check for
* @param actionMethod the method to ckeck for
+ * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever
+ * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with
+ * {@code struts.ui.html5.constraints=true}, which derives native HTML5 constraint attributes
+ * per field instead.
*/
+ @Deprecated(since = "7.4.0", forRemoval = true)
protected void evaluateClientSideJsEnablement(String actionName, String namespace, String actionMethod) {
// Only evaluate if Client-Side js is to be enable when validate=true
@@ -268,8 +282,17 @@ protected void evaluateClientSideJsEnablement(String actionName, String namespac
}
}
+ /**
+ * Looks up the validators for a field, for the deprecated client-side JavaScript validator.
+ *
+ * @param name the field name to look up
+ * @return the validators applying to the field, never null
+ * @deprecated since 7.4.0, for removal in 8.0.0. Use {@link #getFieldValidators(String)}, which
+ * is generically typed and resolves the action's validators once per form rather than per field.
+ */
+ @Deprecated(since = "7.4.0", forRemoval = true)
public List getValidators(String name) {
- Class actionClass = (Class) getAttributes().get("actionClass");
+ Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
if (actionClass == null) {
return Collections.EMPTY_LIST;
}
@@ -300,6 +323,47 @@ public List getValidators(String name) {
return validators;
}
+ /**
+ * Returns the validators declared for a single field, resolving the action's validator list at
+ * most once per form render.
+ *
+ * @since 7.4.0
+ */
+ public List getFieldValidators(String name) {
+ resolveActionValidators();
+ if (cachedActionValidators.isEmpty()) {
+ return Collections.emptyList();
+ }
+ Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
+ List validators = new ArrayList<>();
+ findFieldValidators(name, actionClass, cachedActionName, cachedActionValidators, validators, "");
+ return validators;
+ }
+
+ private void resolveActionValidators() {
+ if (actionValidatorsResolved) {
+ return;
+ }
+ actionValidatorsResolved = true;
+ cachedActionValidators = Collections.emptyList();
+
+ Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
+ if (actionClass == null) {
+ return;
+ }
+ ActionMapping mapping = actionMapper.getMappingFromActionName(findString(action));
+ if (mapping == null) {
+ mapping = actionMapper.getMappingFromActionName((String) getAttributes().get("actionName"));
+ }
+ if (mapping == null) {
+ return;
+ }
+ cachedActionName = mapping.getName();
+ String methodName = isValidateAnnotatedMethodOnly(cachedActionName) ? mapping.getMethod() : null;
+ cachedActionValidators =
+ actionValidatorManager.getValidators(actionClass, cachedActionName, methodName);
+ }
+
private boolean isValidateAnnotatedMethodOnly(String actionName) {
RuntimeConfiguration runtimeConfiguration = configuration.getRuntimeConfiguration();
String actionNamespace = getNamespace(stack);
@@ -507,8 +571,14 @@ public void setNamespace(String namespace) {
this.namespace = namespace;
}
+ /**
+ * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever
+ * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with
+ * {@code struts.ui.html5.constraints=true} instead.
+ */
@StrutsTagAttribute(description = "Whether client side/remote validation should be performed. Only" +
" useful with theme xhtml/ajax", type = "Boolean", defaultValue = "false")
+ @Deprecated(since = "7.4.0", forRemoval = true)
public void setValidate(String validate) {
this.validate = validate;
}
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 0000000000..6fdeaa52be
--- /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.
+ *
+ * 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 constraintsFor(List validators, HtmlControlType control, Object action);
+}
diff --git a/core/src/main/java/org/apache/struts2/components/HtmlControlType.java b/core/src/main/java/org/apache/struts2/components/HtmlControlType.java
new file mode 100644
index 0000000000..6e69617b98
--- /dev/null
+++ b/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.
+ *
+ * This models the control 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,
+ TEXTAREA,
+ OTHER;
+
+ private static final Set TEXT_ENTRY = EnumSet.of(TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL);
+ private static final Set NUMERIC = EnumSet.of(NUMBER, RANGE);
+ private static final Set TEMPORAL = EnumSet.of(DATE, MONTH, WEEK, TIME, DATETIME_LOCAL);
+
+ /**
+ * Resolves a raw {@code type} attribute value. Never throws: the attribute is OGNL-evaluated, so at
+ * runtime it can be any string. Anything unrecognised becomes {@link #OTHER}, which supports no
+ * constraints at all — so an unknown control degrades to emitting nothing.
+ */
+ public static HtmlControlType from(String type) {
+ if (type == null) {
+ return OTHER;
+ }
+ String normalised = type.trim().toUpperCase(Locale.ROOT).replace('-', '_');
+ if (normalised.isEmpty()) {
+ return OTHER;
+ }
+ try {
+ return valueOf(normalised);
+ } catch (IllegalArgumentException e) {
+ return OTHER;
+ }
+ }
+
+ public boolean supportsPattern() {
+ return TEXT_ENTRY.contains(this);
+ }
+
+ public boolean supportsLength() {
+ return TEXT_ENTRY.contains(this) || this == TEXTAREA;
+ }
+
+ public boolean supportsRange() {
+ return NUMERIC.contains(this) || TEMPORAL.contains(this);
+ }
+}
diff --git a/core/src/main/java/org/apache/struts2/components/Password.java b/core/src/main/java/org/apache/struts2/components/Password.java
index 471f7dbd24..4bd75c1a18 100644
--- a/core/src/main/java/org/apache/struts2/components/Password.java
+++ b/core/src/main/java/org/apache/struts2/components/Password.java
@@ -64,6 +64,11 @@ protected String getDefaultTemplate() {
return TEMPLATE;
}
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.PASSWORD;
+ }
+
public void evaluateExtraParams() {
super.evaluateExtraParams();
diff --git a/core/src/main/java/org/apache/struts2/components/Radio.java b/core/src/main/java/org/apache/struts2/components/Radio.java
index d0d3eb1465..bc6bf76069 100644
--- a/core/src/main/java/org/apache/struts2/components/Radio.java
+++ b/core/src/main/java/org/apache/struts2/components/Radio.java
@@ -74,4 +74,9 @@ protected boolean lazyEvaluation() {
return true;
}
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.RADIO;
+ }
+
}
diff --git a/core/src/main/java/org/apache/struts2/components/Select.java b/core/src/main/java/org/apache/struts2/components/Select.java
index f1a8e5b6df..237775fce8 100644
--- a/core/src/main/java/org/apache/struts2/components/Select.java
+++ b/core/src/main/java/org/apache/struts2/components/Select.java
@@ -97,6 +97,11 @@ protected String getDefaultTemplate() {
return TEMPLATE;
}
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.SELECT;
+ }
+
public void evaluateExtraParams() {
super.evaluateExtraParams();
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 0000000000..79790aabb1
--- /dev/null
+++ b/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}.
+ *
+ * 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 never sets or changes
+ * an input's {@code type} — 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 constraintsFor(List validators, HtmlControlType control, Object action) {
+ Map 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 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 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 attributes, HtmlControlType control) {
+ if (control != HtmlControlType.RADIO && control != HtmlControlType.FILE) {
+ return;
+ }
+ attributes.put(REQUIRED, REQUIRED);
+ }
+
+ protected void addLength(Map 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 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 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 would not.
+ Object min = validator.getMin();
+ if (isIntegral(min)) {
+ putIfPresent(attributes, "min", min);
+ }
+ putIfPresent(attributes, "max", validator.getMax());
+ }
+
+ protected void addDoubleRange(Map 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);
+ }
+
+ protected void addMessage(Map 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 attributes, String name, Object value) {
+ if (value != null) {
+ attributes.put(name, String.valueOf(value));
+ }
+ }
+}
diff --git a/core/src/main/java/org/apache/struts2/components/TextArea.java b/core/src/main/java/org/apache/struts2/components/TextArea.java
index 7f3babf561..41856b2709 100644
--- a/core/src/main/java/org/apache/struts2/components/TextArea.java
+++ b/core/src/main/java/org/apache/struts2/components/TextArea.java
@@ -62,6 +62,11 @@ protected String getDefaultTemplate() {
return TEMPLATE;
}
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.TEXTAREA;
+ }
+
public void evaluateExtraParams() {
super.evaluateExtraParams();
diff --git a/core/src/main/java/org/apache/struts2/components/TextField.java b/core/src/main/java/org/apache/struts2/components/TextField.java
index 726c4fc5b3..e72772deed 100644
--- a/core/src/main/java/org/apache/struts2/components/TextField.java
+++ b/core/src/main/java/org/apache/struts2/components/TextField.java
@@ -72,6 +72,12 @@ protected String getDefaultTemplate() {
return TEMPLATE;
}
+ @Override
+ protected HtmlControlType getControlType() {
+ Object resolvedType = getAttributes().get("type");
+ return resolvedType == null ? HtmlControlType.TEXT : HtmlControlType.from(String.valueOf(resolvedType));
+ }
+
protected void evaluateExtraParams() {
super.evaluateExtraParams();
diff --git a/core/src/main/java/org/apache/struts2/components/UIBean.java b/core/src/main/java/org/apache/struts2/components/UIBean.java
index adac94dbaa..6e73392e8b 100644
--- a/core/src/main/java/org/apache/struts2/components/UIBean.java
+++ b/core/src/main/java/org/apache/struts2/components/UIBean.java
@@ -25,6 +25,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
+import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -531,6 +532,9 @@ public UIBean(ValueStack stack, HttpServletRequest request, HttpServletResponse
protected CspNonceReader cspNonceReader;
+ protected HtmlConstraintProvider htmlConstraintProvider;
+ protected boolean html5ConstraintsEnabled;
+
@Inject(StrutsConstants.STRUTS_UI_TEMPLATEDIR)
public void setDefaultTemplateDir(String dir) {
this.defaultTemplateDir = dir;
@@ -561,6 +565,16 @@ public void setCspNonceReader(CspNonceReader cspNonceReader) {
this.cspNonceReader = cspNonceReader;
}
+ @Inject
+ public void setHtmlConstraintProvider(HtmlConstraintProvider htmlConstraintProvider) {
+ this.htmlConstraintProvider = htmlConstraintProvider;
+ }
+
+ @Inject(value = StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, required = false)
+ public void setHtml5ConstraintsEnabled(String html5ConstraintsEnabled) {
+ this.html5ConstraintsEnabled = BooleanUtils.toBoolean(html5ConstraintsEnabled);
+ }
+
@Override
public boolean end(Writer writer, String body) {
evaluateParams();
@@ -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.
+ *
+ * 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 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.
+ *
+ * {@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);
}
/**
@@ -968,6 +1045,17 @@ protected String ensureAttributeSafelyNotEscaped(String val) {
}
}
+ /**
+ * The kind of HTML control this component renders, used to decide which HTML5 constraint
+ * attributes are legal on it. Defaults to {@link HtmlControlType#OTHER}, which supports no
+ * constraints — so a component that does not override this emits none.
+ *
+ * @since 7.4.0
+ */
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.OTHER;
+ }
+
protected void evaluateExtraParams() {
}
diff --git a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
index f37cb47520..b0dda639b2 100644
--- a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
+++ b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
@@ -29,6 +29,7 @@
import org.apache.struts2.text.TextProvider;
import org.apache.struts2.text.TextProviderFactory;
import org.apache.struts2.UnknownHandlerManager;
+import org.apache.struts2.components.HtmlConstraintProvider;
import org.apache.struts2.components.UrlRenderer;
import org.apache.struts2.components.date.DateFormatter;
import org.apache.struts2.conversion.ConversionAnnotationProcessor;
@@ -424,6 +425,7 @@ public void register(ContainerBuilder builder, LocatableProperties props) {
alias(MultiPartRequest.class, StrutsConstants.STRUTS_MULTIPART_PARSER, builder, props, Scope.PROTOTYPE);
alias(FreemarkerManager.class, StrutsConstants.STRUTS_FREEMARKER_MANAGER_CLASSNAME, builder, props);
alias(UrlRenderer.class, StrutsConstants.STRUTS_URL_RENDERER, builder, props);
+ alias(HtmlConstraintProvider.class, StrutsConstants.STRUTS_HTML_CONSTRAINT_PROVIDER, builder, props);
alias(ActionValidatorManager.class, StrutsConstants.STRUTS_ACTIONVALIDATORMANAGER, builder, props);
alias(ValueStackFactory.class, StrutsConstants.STRUTS_VALUESTACKFACTORY, builder, props);
alias(ReflectionProvider.class, StrutsConstants.STRUTS_REFLECTIONPROVIDER, builder, props);
diff --git a/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java b/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java
index 3bdb483798..b646702477 100644
--- a/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java
+++ b/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java
@@ -54,6 +54,7 @@ public Component getBean(ValueStack stack, HttpServletRequest req, HttpServletRe
}
@Override
+ @SuppressWarnings("removal") // must keep forwarding `validate` until it is removed in 8.0.0
protected void populateParams() {
super.populateParams();
Form form = ((Form) component);
@@ -93,6 +94,12 @@ public void setNamespace(String namespace) {
this.namespace = namespace;
}
+ /**
+ * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever
+ * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with
+ * {@code struts.ui.html5.constraints=true} instead.
+ */
+ @Deprecated(since = "7.4.0", forRemoval = true)
public void setValidate(String validate) {
this.validate = validate;
}
diff --git a/core/src/main/resources/org/apache/struts2/default.properties b/core/src/main/resources/org/apache/struts2/default.properties
index a14c84bcc3..a35001534e 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -171,11 +171,20 @@ struts.ui.theme.expansion.token=~~~
### Sets the default template type. Either ftl, vm, or jsp
struts.ui.templateSuffix=ftl
+### Whether the html5 theme emits HTML5 constraint attributes (required, minlength,
+### maxlength, pattern, min, max) derived from the action's validators.
+### Defaults to false so existing html5-theme forms render unchanged; the default is
+### expected to flip in a future major release.
+struts.ui.html5.constraints=false
+
### Sets a global flag which will escape html body of Anchor, Submit and Component tag
### You can control this flag per tag, e.g.: ...
### and this take precedence over the global flag
# struts.ui.escapeHtmlBody=true
+### The HtmlConstraintProvider implementation used to derive HTML5 constraint attributes
+struts.htmlConstraintProvider=struts
+
### Configuration reloading
### This will cause the configuration to reload struts.xml when it is changed
# struts.configuration.xml.reload=false
diff --git a/core/src/main/resources/struts-beans.xml b/core/src/main/resources/struts-beans.xml
index 84f0919dcd..8ad4dff8be 100644
--- a/core/src/main/resources/struts-beans.xml
+++ b/core/src/main/resources/struts-beans.xml
@@ -145,6 +145,8 @@
+
diff --git a/core/src/main/resources/template/html5/common-attributes.ftl b/core/src/main/resources/template/html5/common-attributes.ftl
index 424316dad6..d8730c23a4 100644
--- a/core/src/main/resources/template/html5/common-attributes.ftl
+++ b/core/src/main/resources/template/html5/common-attributes.ftl
@@ -21,3 +21,4 @@
<#if attributes.accesskey?has_content>
accesskey="${attributes.accesskey}"<#rt/>
#if>
+<#include "/${attributes.templateDir}/${attributes.expandTheme}/constraints.ftl" /><#rt/>
diff --git a/core/src/main/resources/template/html5/constraints.ftl b/core/src/main/resources/template/html5/constraints.ftl
new file mode 100644
index 0000000000..ad86c25b0f
--- /dev/null
+++ b/core/src/main/resources/template/html5/constraints.ftl
@@ -0,0 +1,25 @@
+<#--
+/*
+ * 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.
+ */
+-->
+<#if attributes.constraints??>
+<#list attributes.constraints as attributeName, attributeValue>
+ ${attributeName}="${attributeValue}"<#rt/>
+#list>
+#if>
diff --git a/core/src/main/resources/template/xhtml/form-close-validate.ftl b/core/src/main/resources/template/xhtml/form-close-validate.ftl
index 8e86615683..a815105a19 100644
--- a/core/src/main/resources/template/xhtml/form-close-validate.ftl
+++ b/core/src/main/resources/template/xhtml/form-close-validate.ftl
@@ -19,6 +19,13 @@
*/
-->
<#--
+DEPRECATED since Struts 7.4.0, removed in 8.0.0 (WW-5694 / WW-5696).
+
+JavaScript client-side validation is superseded by native HTML5 constraint
+attributes in the html5 theme (WW-5695). This template, form-validate.ftl and
+validation.js are all removed in 8.0.0.
+-->
+<#--
START SNIPPET: supported-validators
Only the following validators are supported:
* required validator
diff --git a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
index 34d1ecd5b6..c6fabf7427 100644
--- a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
+++ b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
@@ -38,6 +38,7 @@
import org.apache.struts2.interceptor.TokenSessionStoreInterceptor;
import org.apache.struts2.interceptor.parameter.ParametersInterceptor;
import org.apache.struts2.result.ServletDispatcherResult;
+import org.apache.struts2.components.ConstraintAction;
import org.apache.struts2.views.jsp.ui.DoubleValidationAction;
import java.util.HashMap;
@@ -94,6 +95,13 @@ public void loadPackages() {
.addInterceptor(new InterceptorMapping("validation", validationInterceptor))
.build();
+ ActionConfig constraintActionConfig = new ActionConfig.Builder("", "constraintAction", ConstraintAction.class.getName())
+ .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName())
+ .addParam("location", "success.jsp")
+ .build())
+ .addInterceptor(new InterceptorMapping("validation", validationInterceptor))
+ .build();
+
ActionConfig testActionConfig = new ActionConfig.Builder("", "", TestAction.class.getName())
.addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName())
.addParam("location", "success.jsp")
@@ -119,6 +127,7 @@ public void loadPackages() {
.addActionConfig(EXECUTION_COUNT_ACTION_NAME, executionCountActionConfig)
.addActionConfig(TEST_ACTION_NAME, testActionConfig)
.addActionConfig("doubleValidationAction", doubleValidationActionConfig)
+ .addActionConfig("constraintAction", constraintActionConfig)
.addActionConfig(TOKEN_ACTION_NAME, tokenActionConfig)
.addActionConfig(TOKEN_SESSION_ACTION_NAME, tokenSessionActionConfig)
.addActionConfig("testActionTagAction", new ActionConfig.Builder("", "", TestAction.class.getName())
diff --git a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
new file mode 100644
index 0000000000..2a1f9b4f32
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
@@ -0,0 +1,56 @@
+/*
+ * 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.interceptor.parameter.StrutsParameter;
+
+public class ConstraintAction extends ActionSupport {
+
+ private String username;
+ private String comment;
+ private String bio;
+
+ public String getUsername() {
+ return username;
+ }
+
+ @StrutsParameter
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getComment() {
+ return comment;
+ }
+
+ @StrutsParameter
+ public void setComment(String comment) {
+ this.comment = comment;
+ }
+
+ public String getBio() {
+ return bio;
+ }
+
+ @StrutsParameter
+ public void setBio(String bio) {
+ this.bio = bio;
+ }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
new file mode 100644
index 0000000000..319c001236
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.StrutsConstants;
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.mock.MockActionProxy;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+import org.apache.struts2.views.jsp.ui.FormTag;
+import org.apache.struts2.views.jsp.ui.TextFieldTag;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class ConstraintAttributesTest extends AbstractUITagTest {
+
+ public void testNoConstraintsWhenTheConstantIsOff() throws Exception {
+ initDispatcherWith("false");
+
+ assertNull(renderFieldAndReturnConstraints(null));
+ }
+
+ public void testConstraintsWhenTheConstantIsOn() throws Exception {
+ initDispatcherWith("true");
+
+ Map constraints = renderFieldAndReturnConstraints(null);
+ assertNotNull("expected constraints to be populated", constraints);
+ assertEquals("3", constraints.get("minlength"));
+ }
+
+ /**
+ * Pins the hook to running after {@code evaluateExtraParams()}. A {@code stringlength} validator on
+ * a control the browser treats as numeric must not emit {@code minlength} at all — that attribute
+ * is not legal there. This can only resolve correctly if the control type ({@code type="number"},
+ * resolved by {@code TextField.evaluateExtraParams()}) is already known when the constraint hook
+ * fires. Untyped text fields resolve to {@code TEXT} either way, so
+ * {@link #testConstraintsWhenTheConstantIsOn()} alone cannot distinguish a correctly-placed hook
+ * from one hoisted up to the {@code tagNames} block.
+ */
+ public void testConstraintsRespectAnExplicitInputType() throws Exception {
+ initDispatcherWith("true");
+
+ Map constraints = renderFieldAndReturnConstraints("number");
+
+ assertTrue("expected minlength to be suppressed for a numeric control",
+ constraints == null || !constraints.containsKey("minlength"));
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map renderFieldAndReturnConstraints(String type) throws Exception {
+ FormTag form = new FormTag();
+ form.setPageContext(pageContext);
+ form.setAction("constraintAction");
+ form.setNamespace("");
+ form.doStartTag();
+
+ TextFieldTag field = new TextFieldTag();
+ field.setPageContext(pageContext);
+ field.setName("username");
+ if (type != null) {
+ field.setType(type);
+ }
+ field.doStartTag();
+
+ Map attributes =
+ ((UIBean) field.getComponent()).getAttributes();
+
+ field.doEndTag();
+ form.doEndTag();
+
+ return (Map) attributes.get("constraints");
+ }
+
+ private void initDispatcherWith(String constraintsEnabled) {
+ initDispatcher(new HashMap() {{
+ put("configProviders", TestConfigurationProvider.class.getName());
+ put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled);
+ }});
+ createMocks();
+ // createMocks() never sets a config on the MockActionProxy it builds; without one,
+ // AnnotationActionValidatorManager.buildValidatorKey NPEs dereferencing proxy.getConfig().
+ ((MockActionProxy) actionProxy).setConfig(
+ configuration.getRuntimeConfiguration().getActionConfig("", "constraintAction"));
+ }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
new file mode 100644
index 0000000000..c43cfe6b0e
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.views.jsp.AbstractUITagTest;
+
+public class ControlTypeTest extends AbstractUITagTest {
+
+ public void testTextFieldDefaultsToText() {
+ TextField textField = new TextField(stack, request, response);
+ assertEquals(HtmlControlType.TEXT, textField.getControlType());
+ }
+
+ public void testTextFieldHonoursAnExplicitType() {
+ TextField textField = new TextField(stack, request, response);
+ textField.addParameter("type", "number");
+ assertEquals(HtmlControlType.NUMBER, textField.getControlType());
+ }
+
+ public void testTextFieldFallsBackForAnUnknownType() {
+ TextField textField = new TextField(stack, request, response);
+ textField.addParameter("type", "supercolor");
+ assertEquals(HtmlControlType.OTHER, textField.getControlType());
+ }
+
+ public void testPasswordIsAlwaysPassword() {
+ Password password = new Password(stack, request, response);
+ assertEquals(HtmlControlType.PASSWORD, password.getControlType());
+ }
+
+ public void testTextAreaIsTextarea() {
+ TextArea textArea = new TextArea(stack, request, response);
+ assertEquals(HtmlControlType.TEXTAREA, textArea.getControlType());
+ }
+
+ public void testSelectIsSelect() {
+ Select select = new Select(stack, request, response);
+ assertEquals(HtmlControlType.SELECT, select.getControlType());
+ }
+
+ public void testRadioIsRadio() {
+ Radio radio = new Radio(stack, request, response);
+ assertEquals(HtmlControlType.RADIO, radio.getControlType());
+ }
+
+ public void testFileIsFile() {
+ File file = new File(stack, request, response);
+ assertEquals(HtmlControlType.FILE, file.getControlType());
+ }
+
+ public void testControlsWithoutAnOverrideAreUnknown() {
+ // CheckboxInterceptor substitutes "false" for an unticked box, so the server accepts what
+ // a browser "required" would block — that is a real false reject, and the reason Checkbox
+ // and Hidden deliberately have no getControlType() override.
+ assertEquals(HtmlControlType.OTHER, new Checkbox(stack, request, response).getControlType());
+ assertEquals(HtmlControlType.OTHER, new Hidden(stack, request, response).getControlType());
+ }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
new file mode 100644
index 0000000000..a895d1337b
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class EcmaScriptSafeRegexTest {
+
+ @Test
+ public void acceptsPortableConstructs() {
+ assertThat(EcmaScriptSafeRegex.isSafe("[a-z]+")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\d{3}-\\d{4}")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("(foo|bar)?baz")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("^\\w+@\\w+\\.\\w{2,6}$")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("(?:ab)+")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("a(?=b)")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("a(?!b)")).isTrue();
+ }
+
+ @Test
+ public void rejectsJavaOnlyEscapes() {
+ assertThat(EcmaScriptSafeRegex.isSafe("\\p{Alpha}+")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\A\\d+\\z")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\Qliteral\\E")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\h+")).isFalse();
+ }
+
+ @Test
+ public void rejectsPossessiveQuantifiers() {
+ assertThat(EcmaScriptSafeRegex.isSafe("\\d++")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("a*+")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("a?+")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("a{2,3}+")).isFalse();
+ }
+
+ @Test
+ public void rejectsNonPortableGroups() {
+ assertThat(EcmaScriptSafeRegex.isSafe("(?a)")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("(?<=a)b")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("(?>a)")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("(?i)abc")).isFalse();
+ }
+
+ @Test
+ public void rejectsJavaCharacterClassFeatures() {
+ assertThat(EcmaScriptSafeRegex.isSafe("[[:alpha:]]")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("[a-z&&[^aeiou]]")).isFalse();
+ }
+
+ @Test
+ public void rejectsUnusableInput() {
+ assertThat(EcmaScriptSafeRegex.isSafe(null)).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("abc\\")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("[abc")).isFalse();
+ }
+
+ @Test
+ public void rejectsWhitespaceClassesWhoseMeaningDiffersBetweenEngines() {
+ // Java's \s is ASCII-only by default; ECMAScript's includes NBSP and friends, so
+ // ^\S+$ accepts a value containing NBSP on the server and rejects it in the browser
+ assertThat(EcmaScriptSafeRegex.isSafe("^\\S+$")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\s*")).isFalse();
+ }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
new file mode 100644
index 0000000000..cdc2fbfc2b
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.TestConfigurationProvider;
+import org.apache.struts2.mock.MockActionProxy;
+import org.apache.struts2.validator.ActionValidatorManager;
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+import org.apache.struts2.views.jsp.ui.FormTag;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.when;
+
+public class FormFieldValidatorsTest extends AbstractUITagTest {
+
+ public void testFindsTheFieldsValidators() throws Exception {
+ Form form = formForDoubleValidationAction();
+
+ List validators = form.getFieldValidators("myUpDownSelectTag");
+
+ assertEquals(1, validators.size());
+ assertEquals("double", validators.get(0).getValidatorType());
+ }
+
+ public void testReturnsEmptyForAnUnvalidatedField() throws Exception {
+ Form form = formForDoubleValidationAction();
+
+ assertTrue(form.getFieldValidators("noSuchField").isEmpty());
+ }
+
+ public void testResolvesTheActionsValidatorsOnlyOnceAcrossFields() throws Exception {
+ Form form = formForDoubleValidationAction();
+
+ ActionValidatorManager manager = mock(ActionValidatorManager.class);
+ when(manager.getValidators(any(Class.class), anyString(), nullable(String.class)))
+ .thenReturn(Collections.emptyList());
+ form.setActionValidatorManager(manager);
+
+ form.getFieldValidators("myUpDownSelectTag");
+ form.getFieldValidators("someOtherField");
+
+ // fully qualified: AbstractUITagTest inherits verify(URL), which would shadow a static import
+ org.mockito.Mockito.verify(manager, times(1))
+ .getValidators(any(Class.class), anyString(), nullable(String.class));
+ }
+
+ private Form formForDoubleValidationAction() throws Exception {
+ FormTag tag = new FormTag();
+ tag.setPageContext(pageContext);
+ tag.setName("myForm");
+ tag.setAction("doubleValidationAction");
+ tag.setNamespace("");
+ tag.doStartTag();
+ return (Form) tag.getComponent();
+ }
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ initDispatcher(new HashMap() {{
+ put("configProviders", TestConfigurationProvider.class.getName());
+ }});
+ createMocks();
+ // AnnotationActionValidatorManager.buildValidatorKey() dereferences the current ActionInvocation's
+ // proxy config; at real runtime the Dispatcher always attaches one, but the mock proxy from
+ // createMocks() does not, so it has to be wired up explicitly here.
+ ((MockActionProxy) actionProxy).setConfig(
+ configuration.getRuntimeConfiguration().getActionConfig("", "doubleValidationAction"));
+ }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java b/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java
new file mode 100644
index 0000000000..9fa862cdd6
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.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 org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class HtmlControlTypeTest {
+
+ @Test
+ public void resolvesKnownTypes() {
+ assertThat(HtmlControlType.from("text")).isEqualTo(HtmlControlType.TEXT);
+ assertThat(HtmlControlType.from("number")).isEqualTo(HtmlControlType.NUMBER);
+ assertThat(HtmlControlType.from("datetime-local")).isEqualTo(HtmlControlType.DATETIME_LOCAL);
+ }
+
+ @Test
+ public void isLenientAboutCaseAndWhitespace() {
+ assertThat(HtmlControlType.from(" NuMbEr ")).isEqualTo(HtmlControlType.NUMBER);
+ }
+
+ @Test
+ public void neverThrowsOnUnusableInput() {
+ assertThat(HtmlControlType.from(null)).isEqualTo(HtmlControlType.OTHER);
+ assertThat(HtmlControlType.from("")).isEqualTo(HtmlControlType.OTHER);
+ assertThat(HtmlControlType.from(" ")).isEqualTo(HtmlControlType.OTHER);
+ assertThat(HtmlControlType.from("supercolor")).isEqualTo(HtmlControlType.OTHER);
+ }
+
+ @Test
+ public void otherSupportsNothing() {
+ assertThat(HtmlControlType.OTHER.supportsPattern()).isFalse();
+ assertThat(HtmlControlType.OTHER.supportsLength()).isFalse();
+ assertThat(HtmlControlType.OTHER.supportsRange()).isFalse();
+ }
+
+ @Test
+ public void patternIsTextEntryOnly() {
+ assertThat(HtmlControlType.TEXT.supportsPattern()).isTrue();
+ assertThat(HtmlControlType.PASSWORD.supportsPattern()).isTrue();
+ assertThat(HtmlControlType.NUMBER.supportsPattern()).isFalse();
+ assertThat(HtmlControlType.TEXTAREA.supportsPattern()).isFalse();
+ assertThat(HtmlControlType.SELECT.supportsPattern()).isFalse();
+ }
+
+ @Test
+ public void lengthIsTextEntryPlusTextarea() {
+ assertThat(HtmlControlType.TEXT.supportsLength()).isTrue();
+ assertThat(HtmlControlType.TEXTAREA.supportsLength()).isTrue();
+ assertThat(HtmlControlType.NUMBER.supportsLength()).isFalse();
+ assertThat(HtmlControlType.CHECKBOX.supportsLength()).isFalse();
+ }
+
+ @Test
+ public void rangeIsNumericAndTemporalOnly() {
+ assertThat(HtmlControlType.NUMBER.supportsRange()).isTrue();
+ assertThat(HtmlControlType.RANGE.supportsRange()).isTrue();
+ assertThat(HtmlControlType.DATE.supportsRange()).isTrue();
+ assertThat(HtmlControlType.TEXT.supportsRange()).isFalse();
+ }
+}
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 0000000000..0cc97eab0f
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
@@ -0,0 +1,312 @@
+/*
+ * 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.CreditCardValidator;
+import org.apache.struts2.validator.validators.DateRangeFieldValidator;
+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.Date;
+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 constraints(Validator validator, HtmlControlType control) {
+ return provider.constraintsFor(singletonList(validator), control, null);
+ }
+
+ @Test
+ public void requiredStringEmitsRequiredEvenThoughServerIsStricter() {
+ assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXT))
+ .containsEntry("required", "required");
+ }
+
+ @Test
+ public void requiredStringEmitsRequiredOnTextarea() {
+ assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXTAREA))
+ .containsEntry("required", "required");
+ }
+
+ @Test
+ public void requiredFieldEmitsNothingOnATextControlBecauseEmptyStringWouldPassServerSide() {
+ // an empty text input submits name="", which RequiredFieldValidator accepts (it only
+ // rejects null / empty array / empty collection) — required here would false-reject
+ assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.TEXT)).isEmpty();
+ }
+
+ @Test
+ public void requiredFieldEmitsNothingOnACheckboxBecauseUncheckedSubstitutesFalse() {
+ // CheckboxInterceptor substitutes "false" for an unticked box, so the field is never
+ // null server-side and an unticked required checkbox would still pass validation
+ assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.CHECKBOX)).isEmpty();
+ }
+
+ @Test
+ public void requiredFieldEmitsNothingOnASelectBecauseAnEmptyOptionWouldPassServerSide() {
+ // a select with an empty-valued header option submits "", which passes server-side
+ assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.SELECT)).isEmpty();
+ }
+
+ @Test
+ public void requiredFieldEmitsRequiredOnRadioBecauseNoSelectionOmitsTheParameter() {
+ // an unselected radio group omits the parameter entirely, agreeing with the server
+ assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.RADIO))
+ .containsEntry("required", "required");
+ }
+
+ @Test
+ public void requiredFieldEmitsRequiredOnFileBecauseNoSelectionOmitsTheParameter() {
+ // an empty file input omits the parameter entirely, agreeing with the server
+ assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.FILE))
+ .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 stringLengthOmitsMinlengthWhenOnlyMaxLengthIsSet() {
+ StringLengthFieldValidator validator = new StringLengthFieldValidator();
+ validator.setTrim(false);
+ validator.setMaxLength(10);
+
+ Map result = constraints(validator, HtmlControlType.TEXT);
+
+ // minLength defaults to the -1 sentinel (unset), which must not become "minlength=-1"
+ assertThat(result)
+ .containsEntry("maxlength", "10")
+ .doesNotContainKey("minlength");
+ }
+
+ @Test
+ public void regexEmitsPatternWhenPortableAndCaseSensitive() {
+ RegexFieldValidator validator = new RegexFieldValidator();
+ validator.setRegex("[a-z]+");
+ validator.setCaseSensitive(true);
+ validator.setTrim(false);
+
+ assertThat(constraints(validator, HtmlControlType.TEXT))
+ .containsEntry("pattern", "[a-z]+");
+ }
+
+ @Test
+ public void regexEmitsNothingWhenCaseInsensitive() {
+ RegexFieldValidator validator = new RegexFieldValidator();
+ validator.setRegex("[a-z]+");
+ validator.setCaseSensitive(false);
+ validator.setTrim(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);
+ validator.setTrim(false);
+
+ assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+ }
+
+ @Test
+ public void regexEmitsNothingWhenTrimming() {
+ // trim defaults to true: the server matches the trimmed value while pattern matches the
+ // raw one, so "abc " would pass server-side and be blocked by the browser
+ RegexFieldValidator validator = new RegexFieldValidator();
+ validator.setRegex("[a-z]+");
+ validator.setCaseSensitive(true);
+
+ assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+ }
+
+ @Test
+ public void creditCardValidatorNeverContributesAPatternConstraint() {
+ // CreditCardValidator strips all whitespace before matching, so its regex cannot be
+ // expressed as a browser pattern without also stripping whitespace client-side.
+ // caseSensitive and trim are set explicitly here so this test actually reaches the
+ // EmailValidator/CreditCardValidator exclusion in addPattern, rather than returning
+ // earlier at the case-sensitivity guard (the constructor defaults caseSensitive to false).
+ CreditCardValidator validator = new CreditCardValidator();
+ validator.setCaseSensitive(true);
+ validator.setTrim(false);
+
+ 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() {
+ // integral bounds here, deliberately: a fractional min is covered separately by
+ // doubleRangeOmitsMinWhenItIsFractionalBecauseItWouldShiftTheStepBase, since it must NOT
+ // emit min at all (it would shift the HTML step base off zero)
+ DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator();
+ validator.setMinInclusive(6000.0);
+ validator.setMaxInclusive(10000.1);
+
+ assertThat(constraints(validator, HtmlControlType.NUMBER))
+ .containsEntry("min", "6000.0")
+ .containsEntry("max", "10000.1");
+ assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+ }
+
+ @Test
+ public void doubleRangeOmitsMinWhenItIsFractionalBecauseItWouldShiftTheStepBase() {
+ // min becomes the HTML step base, and the default step is 1: min="6000.1" would make the
+ // browser reject 6002, which DoubleRangeFieldValidator accepts server-side. max does not
+ // participate in the step base, so it is unaffected.
+ DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator();
+ validator.setMinInclusive(6000.1);
+ validator.setMaxInclusive(10000.1);
+
+ Map result = constraints(validator, HtmlControlType.NUMBER);
+
+ assertThat(result)
+ .containsEntry("max", "10000.1")
+ .doesNotContainKey("min");
+ }
+
+ @Test
+ public void doubleRangeEmitsMinWhenItIsIntegral() {
+ DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator();
+ validator.setMinInclusive(6000.0);
+ validator.setMaxInclusive(10000.0);
+
+ assertThat(constraints(validator, HtmlControlType.NUMBER))
+ .containsEntry("min", "6000.0")
+ .containsEntry("max", "10000.0");
+ }
+
+ @Test
+ public void dateRangeEmitsNothingBecauseTemporalFormattingIsDeferred() {
+ DateRangeFieldValidator validator = new DateRangeFieldValidator();
+ validator.setMin(new Date(0));
+ validator.setMax(new Date(1_000_000L));
+
+ assertThat(constraints(validator, HtmlControlType.DATE)).isEmpty();
+ }
+
+ @Test
+ public void emailValidatorNeverContributesAConstraint() {
+ // the browser's email grammar differs from EmailValidator's, so honouring it could reject
+ // an address the server accepts. caseSensitive and trim are set explicitly here so this
+ // test actually reaches addPattern's EmailValidator exclusion, rather than returning
+ // earlier at the case-sensitivity guard or the isTrimed() guard (the constructor defaults
+ // caseSensitive to false, and trim defaults to true).
+ EmailValidator textControlValidator = new EmailValidator();
+ textControlValidator.setCaseSensitive(true);
+ textControlValidator.setTrim(false);
+ assertThat(constraints(textControlValidator, HtmlControlType.TEXT)).isEmpty();
+
+ EmailValidator emailControlValidator = new EmailValidator();
+ emailControlValidator.setCaseSensitive(true);
+ emailControlValidator.setTrim(false);
+ assertThat(constraints(emailControlValidator, 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 result =
+ provider.constraintsFor(singletonList(validator), HtmlControlType.TEXT, action);
+
+ assertThat(result).containsEntry("data-msg-email", "not an email");
+ }
+}
diff --git a/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
new file mode 100644
index 0000000000..c5979e36d5
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.views.jsp.ui;
+
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.mock.MockActionProxy;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+
+import java.util.HashMap;
+
+public class Html5ConstraintRenderingTest extends AbstractUITagTest {
+
+ public void testRendersConstraintAttributes() throws Exception {
+ String output = render("true");
+
+ assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\""));
+ }
+
+ public void testRendersNothingWhenTheConstantIsOff() throws Exception {
+ String output = render("false");
+
+ assertFalse("expected no minlength in: " + output, output.contains("minlength="));
+ }
+
+ public void testRendersExactMarkupWhenTheConstantIsOff() throws Exception {
+ String output = render("false");
+
+ assertEquals("", output);
+ }
+
+ public void testRequiredLabelDoesNotBecomeARequiredAttribute() throws Exception {
+ String output = render("true", "username", "true");
+
+ assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\""));
+ assertFalse("requiredLabel draws an asterisk; it must never emit a required attribute: " + output,
+ output.contains("required=\"required\""));
+ }
+
+ /**
+ * text.ftl renders {@code attributes.maxlength} (the developer's own tag attribute) before
+ * including common-attributes.ftl, which renders the derived {@code attributes.constraints} map.
+ * Without suppressing the derived duplicate, a stringlength validator on this field would render
+ * {@code maxlength} twice: once from the tag attribute, once from the constraint.
+ */
+ public void testDeveloperSetMaxlengthSuppressesTheDerivedOne() throws Exception {
+ String output = renderWithMaxlength("bio", "20");
+
+ int firstIndex = output.indexOf("maxlength=");
+ assertTrue("expected a maxlength attribute in: " + output, firstIndex >= 0);
+ assertEquals("expected exactly one maxlength attribute in: " + output,
+ firstIndex, output.lastIndexOf("maxlength="));
+ assertTrue("expected the developer's own value to win: " + output,
+ output.contains("maxlength=\"20\""));
+ }
+
+ /**
+ * data-msg-* values pass through TextParseUtil.translateVariables and can carry user-submitted
+ * content into an HTML attribute. Escaping is applied by FreemarkerManager's HTMLOutputFormat
+ * configuration, not by the template, so this pins it against regression.
+ */
+ public void testDataMsgAttributesAreHtmlEscaped() throws Exception {
+ String output = render("true", "comment", null);
+
+ assertTrue("expected the escaped message in: " + output,
+ output.contains("data-msg-requiredstring=\"Contains "quotes" and <brackets>\""));
+ assertFalse("the raw, unescaped message must never appear in: " + output,
+ output.contains("Contains \"quotes\" and "));
+ }
+
+ private String render(String constraintsEnabled) throws Exception {
+ return render(constraintsEnabled, "username", null);
+ }
+
+ private String render(String constraintsEnabled, String fieldName, String requiredLabel) throws Exception {
+ return render(constraintsEnabled, fieldName, requiredLabel, null);
+ }
+
+ private String renderWithMaxlength(String fieldName, String maxlength) throws Exception {
+ return render("true", fieldName, null, maxlength);
+ }
+
+ private String render(String constraintsEnabled, String fieldName, String requiredLabel, String maxlength) throws Exception {
+ initDispatcher(new HashMap() {{
+ put("configProviders", TestConfigurationProvider.class.getName());
+ put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled);
+ }});
+ createMocks();
+ ((MockActionProxy) actionProxy).setConfig(configuration.getRuntimeConfiguration().getActionConfig("", "constraintAction"));
+
+ FormTag form = new FormTag();
+ form.setPageContext(pageContext);
+ form.setTheme("html5");
+ form.setAction("constraintAction");
+ form.setNamespace("");
+ form.doStartTag();
+
+ TextFieldTag field = new TextFieldTag();
+ field.setPageContext(pageContext);
+ field.setTheme("html5");
+ field.setName(fieldName);
+ if (requiredLabel != null) {
+ field.setRequiredLabel(requiredLabel);
+ }
+ if (maxlength != null) {
+ field.setMaxlength(maxlength);
+ }
+ field.doStartTag();
+ field.doEndTag();
+ form.doEndTag();
+
+ return writer.toString();
+ }
+}
diff --git a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
new file mode 100644
index 0000000000..30bf5dd431
--- /dev/null
+++ b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ false
+ 3
+ username must be at least ${minLength} characters
+
+
+
+
+ Contains "quotes" and <brackets>
+
+
+
+
+ false
+ 10
+ bio must be at most ${maxLength} characters
+
+
+
diff --git a/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md b/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md
new file mode 100644
index 0000000000..2bde1ae1f7
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md
@@ -0,0 +1,1828 @@
+# HTML5 Constraint Validation (7.4.0) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Deprecate the generated JavaScript client-side validator, and let the `html5` theme emit native HTML5 constraint attributes derived from the action's validators.
+
+**Architecture:** A container-registered `HtmlConstraintProvider` maps a field's validators plus its resolved `HtmlControlType` to a map of HTML attributes. `UIBean.evaluateParams` calls it at the end of the method and stashes the result as the `constraints` attribute; a new `html5/constraints.ftl`, included from `common-attributes.ftl`, renders it. Everything is gated behind a new constant defaulting to `false`, so no existing rendering changes.
+
+**Tech Stack:** Java 17, Maven, FreeMarker templates, JUnit 4 + JUnit 3 (`XWorkTestCase`), AssertJ, EasyMock.
+
+**Spec:** `docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md`
+
+**Tickets:** WW-5694 (deprecation, Task 1), WW-5695 (constraints, Tasks 2–9). WW-5696 is the 8.0.0 removal and is **out of scope for this plan**.
+
+## Global Constraints
+
+- Target version string in every `@Deprecated` annotation: `since = "7.4.0", forRemoval = true`.
+- **Every new file — main source AND test source — must carry the Apache licence header**, copied verbatim
+ from a sibling file in the same directory. The code blocks in this plan omit it for brevity; that omission
+ is not permission to skip it. `apache-rat-plugin:check` is bound to the `prepare-package` phase
+ (`pom.xml:549-555`), so `mvn test -DskipAssembly` — the command every task below uses — **never runs the
+ licence check**. A missing header therefore passes every task-level verification in this plan and fails
+ the real build. Add it as you create each file.
+- **Tests are JUnit 4 or JUnit 3. There is no JUnit 5 in this repo.** New pure-unit test classes use `org.junit.Test`. Any test that renders a tag must extend `AbstractUITagTest` (JUnit 3 style: methods named `testXxx()`, no annotation). A Jupiter `@Test` on an `XWorkTestCase` subclass silently never runs.
+- Commit message format: `WW-XXXX (): `. Ticket prefix is mandatory.
+- Never commit to `main`. Work happens on `feature/WW-5695-html5-constraint-validation`.
+- Never `git add -A` in this repo — the tree holds ~20 long-lived untracked files. Stage explicit paths and verify with `git diff --cached --name-only`.
+- Build command: `mvn test -DskipAssembly -pl core -Dtest=` from the repo root.
+- `DateTest.testJavaSqlDate` is a known flake (WW-5686). A red build on that one alone is the clock, not your change.
+- **The governing rule for every mapping decision: never false-reject.** Emit a constraint only when the browser cannot reject input the server would accept. When unsure, emit nothing.
+- Struts ships **no JavaScript** as part of this work.
+
+---
+
+### Task 1: Deprecate the JavaScript client-side validator (WW-5694)
+
+Independent of every other task and separately mergeable. Annotations and documentation only — no behaviour changes.
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/components/Form.java`
+- Modify: `core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java`
+- Modify: `core/src/main/resources/template/xhtml/form-close-validate.ftl`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: nothing. Later tasks do not depend on this one.
+
+- [ ] **Step 1: Annotate the `Form` component**
+
+In `Form.java`, add `@Deprecated(since = "7.4.0", forRemoval = true)` to three members. `setValidate` is around line 512, `getValidators` around line 270, `evaluateClientSideJsEnablement` around line 242 — confirm by search, not by line number.
+
+```java
+ @Deprecated(since = "7.4.0", forRemoval = true)
+ protected void evaluateClientSideJsEnablement(String actionName, String namespace, String actionMethod) {
+
+ @Deprecated(since = "7.4.0", forRemoval = true)
+ public List getValidators(String name) {
+
+ @StrutsTagAttribute(description = "Whether client side/js validation should be performed. Only useful with theme xhtml/ajax", type = "Boolean", defaultValue = "false")
+ @Deprecated(since = "7.4.0", forRemoval = true)
+ public void setValidate(String validate) {
+```
+
+Keep the existing `@StrutsTagAttribute` annotation and its exact text; only add the `@Deprecated` line beneath it.
+
+**Do not touch `Dispatcher.setValidate` or `ValidationInterceptor.setValidate`.** They are unrelated methods that happen to share the name.
+
+- [ ] **Step 2: Annotate the JSP tag layer**
+
+WW-5510 (`c2a5bfe3c`) annotated both the component and the tag. In `FormTag.java` (setter around line 96):
+
+```java
+ @Deprecated(since = "7.4.0", forRemoval = true)
+ public void setValidate(String validate) {
+ this.validate = validate;
+ }
+```
+
+Leave `FormTag.clearTagStateForTagPoolingServers` alone — the field reset stays until removal.
+
+- [ ] **Step 3: Add the Javadoc banner**
+
+Find the `` block in `Form.java`'s class Javadoc that documents `validate`. Add, as the first line inside the snippet:
+
+```java
+ * Deprecated since 7.4.0 — use the html5 theme's constraint attributes instead. Removed in 8.0.0.
+```
+
+This matters because the website pulls these snippets in via the `remote_file_content` Jekyll plugin. Omitting it leaves struts.apache.org advertising the feature as current.
+
+- [ ] **Step 4: Add the template banner**
+
+A `.ftl` carries no annotation, and anyone who overrode this template needs to see the notice there. In `core/src/main/resources/template/xhtml/form-close-validate.ftl`, immediately after the Apache licence header comment:
+
+```
+<#--
+DEPRECATED since Struts 7.4.0, removed in 8.0.0 (WW-5694 / WW-5696).
+
+JavaScript client-side validation is superseded by native HTML5 constraint
+attributes in the html5 theme (WW-5695). This template, form-validate.ftl and
+validation.js are all removed in 8.0.0.
+-->
+```
+
+- [ ] **Step 5: Verify the build still compiles and existing tests pass**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=FormTagTest`
+Expected: PASS. Deprecation annotations change no behaviour, so all four `validateForm_` golden files must still match byte-for-byte.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/Form.java \
+ core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java \
+ core/src/main/resources/template/xhtml/form-close-validate.ftl
+git diff --cached --name-only
+git commit -m "WW-5694 refactor(validation): deprecate JavaScript client-side validation
+
+Marks the form tag's validate attribute and the machinery behind it for
+removal in 8.0.0. Annotations and documentation only; no behaviour change.
+
+Follows the WW-5510 precedent: annotate both the component and the JSP tag,
+and put the notice inside the START SNIPPET block so the website picks it up.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 2: `HtmlControlType` enum
+
+**Files:**
+- Create: `core/src/main/java/org/apache/struts2/components/HtmlControlType.java`
+- Test: `core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `HtmlControlType` with `static HtmlControlType from(String type)`, and instance predicates `boolean supportsPattern()`, `boolean supportsLength()`, `boolean supportsRange()`. Used by Tasks 4, 6, 7.
+
+Package note: `org.apache.struts2.components` is where `UrlRenderer` lives, so a new view-layer extension point belongs there by convention. WW-5689 eventually moves this whole package into a plugin; that is expected and does not change the right answer for 7.4.0.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java` (JUnit 4 — this is a plain object with no container):
+
+```java
+package org.apache.struts2.components;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class HtmlControlTypeTest {
+
+ @Test
+ public void resolvesKnownTypes() {
+ assertThat(HtmlControlType.from("text")).isEqualTo(HtmlControlType.TEXT);
+ assertThat(HtmlControlType.from("number")).isEqualTo(HtmlControlType.NUMBER);
+ assertThat(HtmlControlType.from("datetime-local")).isEqualTo(HtmlControlType.DATETIME_LOCAL);
+ }
+
+ @Test
+ public void isLenientAboutCaseAndWhitespace() {
+ assertThat(HtmlControlType.from(" NuMbEr ")).isEqualTo(HtmlControlType.NUMBER);
+ }
+
+ @Test
+ public void neverThrowsOnUnusableInput() {
+ assertThat(HtmlControlType.from(null)).isEqualTo(HtmlControlType.OTHER);
+ assertThat(HtmlControlType.from("")).isEqualTo(HtmlControlType.OTHER);
+ assertThat(HtmlControlType.from(" ")).isEqualTo(HtmlControlType.OTHER);
+ assertThat(HtmlControlType.from("supercolor")).isEqualTo(HtmlControlType.OTHER);
+ }
+
+ @Test
+ public void otherSupportsNothing() {
+ assertThat(HtmlControlType.OTHER.supportsPattern()).isFalse();
+ assertThat(HtmlControlType.OTHER.supportsLength()).isFalse();
+ assertThat(HtmlControlType.OTHER.supportsRange()).isFalse();
+ }
+
+ @Test
+ public void patternIsTextEntryOnly() {
+ assertThat(HtmlControlType.TEXT.supportsPattern()).isTrue();
+ assertThat(HtmlControlType.PASSWORD.supportsPattern()).isTrue();
+ assertThat(HtmlControlType.NUMBER.supportsPattern()).isFalse();
+ assertThat(HtmlControlType.TEXTAREA.supportsPattern()).isFalse();
+ assertThat(HtmlControlType.SELECT.supportsPattern()).isFalse();
+ }
+
+ @Test
+ public void lengthIsTextEntryPlusTextarea() {
+ assertThat(HtmlControlType.TEXT.supportsLength()).isTrue();
+ assertThat(HtmlControlType.TEXTAREA.supportsLength()).isTrue();
+ assertThat(HtmlControlType.NUMBER.supportsLength()).isFalse();
+ assertThat(HtmlControlType.CHECKBOX.supportsLength()).isFalse();
+ }
+
+ @Test
+ public void rangeIsNumericAndTemporalOnly() {
+ assertThat(HtmlControlType.NUMBER.supportsRange()).isTrue();
+ assertThat(HtmlControlType.RANGE.supportsRange()).isTrue();
+ assertThat(HtmlControlType.DATE.supportsRange()).isTrue();
+ assertThat(HtmlControlType.TEXT.supportsRange()).isFalse();
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=HtmlControlTypeTest`
+Expected: FAIL — compilation error, `HtmlControlType` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `core/src/main/java/org/apache/struts2/components/HtmlControlType.java` with the standard Apache licence header (copy it verbatim from any neighbouring file in the same package), then:
+
+```java
+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.
+ *
+ * This models the control 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,
+ TEXTAREA,
+ OTHER;
+
+ private static final Set TEXT_ENTRY = EnumSet.of(TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL);
+ private static final Set NUMERIC = EnumSet.of(NUMBER, RANGE);
+ private static final Set TEMPORAL = EnumSet.of(DATE, MONTH, WEEK, TIME, DATETIME_LOCAL);
+
+ /**
+ * Resolves a raw {@code type} attribute value. Never throws: the attribute is OGNL-evaluated, so at
+ * runtime it can be any string. Anything unrecognised becomes {@link #OTHER}, which supports no
+ * constraints at all — so an unknown control degrades to emitting nothing.
+ */
+ public static HtmlControlType from(String type) {
+ if (type == null) {
+ return OTHER;
+ }
+ String normalised = type.trim().toUpperCase(Locale.ROOT).replace('-', '_');
+ if (normalised.isEmpty()) {
+ return OTHER;
+ }
+ try {
+ return valueOf(normalised);
+ } catch (IllegalArgumentException e) {
+ return OTHER;
+ }
+ }
+
+ public boolean supportsPattern() {
+ return TEXT_ENTRY.contains(this);
+ }
+
+ public boolean supportsLength() {
+ return TEXT_ENTRY.contains(this) || this == TEXTAREA;
+ }
+
+ public boolean supportsRange() {
+ return NUMERIC.contains(this) || TEMPORAL.contains(this);
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=HtmlControlTypeTest`
+Expected: PASS, 7 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/HtmlControlType.java \
+ core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): add HtmlControlType
+
+Models the kind of form control a UIBean renders, so constraint derivation can
+ask which HTML5 attributes are legal rather than string-matching a type
+attribute. Models the control, not the attribute, because textarea and select
+have no type yet still accept required.
+
+from() never throws; unknown input becomes OTHER, which supports nothing.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 3: ECMAScript-safe regex detection
+
+The highest-risk logic in this work. A regex that Java and the browser interpret differently becomes a false rejection the user cannot get past, so detection is an **allowlist**: anything not provably common to both engines is rejected.
+
+**Files:**
+- Create: `core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java`
+- Test: `core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `EcmaScriptSafeRegex.isSafe(String regex)` returning `boolean`. Used by Task 4.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.components;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class EcmaScriptSafeRegexTest {
+
+ @Test
+ public void acceptsPortableConstructs() {
+ assertThat(EcmaScriptSafeRegex.isSafe("[a-z]+")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\d{3}-\\d{4}")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("(foo|bar)?baz")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("^\\w+@\\w+\\.\\w{2,6}$")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("(?:ab)+")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("a(?=b)")).isTrue();
+ assertThat(EcmaScriptSafeRegex.isSafe("a(?!b)")).isTrue();
+ }
+
+ @Test
+ public void rejectsJavaOnlyEscapes() {
+ assertThat(EcmaScriptSafeRegex.isSafe("\\p{Alpha}+")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\A\\d+\\z")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\Qliteral\\E")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\h+")).isFalse();
+ }
+
+ @Test
+ public void rejectsWhitespaceClassesWhoseMeaningDiffersBetweenEngines() {
+ // Java's \s is ASCII-only by default; ECMAScript's includes NBSP and friends, so
+ // ^\S+$ accepts a value containing NBSP on the server and rejects it in the browser
+ assertThat(EcmaScriptSafeRegex.isSafe("^\\S+$")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("\\s*")).isFalse();
+ }
+
+ @Test
+ public void rejectsPossessiveQuantifiers() {
+ assertThat(EcmaScriptSafeRegex.isSafe("\\d++")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("a*+")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("a?+")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("a{2,3}+")).isFalse();
+ }
+
+ @Test
+ public void rejectsNonPortableGroups() {
+ assertThat(EcmaScriptSafeRegex.isSafe("(?a)")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("(?<=a)b")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("(?>a)")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("(?i)abc")).isFalse();
+ }
+
+ @Test
+ public void rejectsJavaCharacterClassFeatures() {
+ assertThat(EcmaScriptSafeRegex.isSafe("[[:alpha:]]")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("[a-z&&[^aeiou]]")).isFalse();
+ }
+
+ @Test
+ public void rejectsUnusableInput() {
+ assertThat(EcmaScriptSafeRegex.isSafe(null)).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("abc\\")).isFalse();
+ assertThat(EcmaScriptSafeRegex.isSafe("[abc")).isFalse();
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=EcmaScriptSafeRegexTest`
+Expected: FAIL — compilation error, `EcmaScriptSafeRegex` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java` with the Apache licence header, then:
+
+```java
+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.
+ *
+ * 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.
+ *
+ * {@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\\.*+?()[]{}|^$/-";
+
+ private EcmaScriptSafeRegex() {
+ }
+
+ public static boolean isSafe(String regex) {
+ if (regex == null || regex.isEmpty()) {
+ return false;
+ }
+ boolean inCharClass = false;
+ for (int i = 0; i < regex.length(); i++) {
+ char current = regex.charAt(i);
+ switch (current) {
+ case '\\':
+ if (i + 1 >= regex.length() || ALLOWED_ESCAPES.indexOf(regex.charAt(++i)) < 0) {
+ return false;
+ }
+ break;
+ case '[':
+ // Java allows nested classes and POSIX names; ECMAScript allows neither
+ if (inCharClass || regex.startsWith("[:", i)) {
+ return false;
+ }
+ inCharClass = true;
+ break;
+ case ']':
+ inCharClass = false;
+ break;
+ case '&':
+ // Java character-class intersection
+ if (inCharClass && i + 1 < regex.length() && regex.charAt(i + 1) == '&') {
+ return false;
+ }
+ break;
+ case '(':
+ // only non-capturing groups and lookahead are portable; named groups,
+ // lookbehind, atomic groups and inline flags are not
+ if (i + 1 < regex.length() && regex.charAt(i + 1) == '?') {
+ if (i + 2 >= regex.length()) {
+ return false;
+ }
+ char kind = regex.charAt(i + 2);
+ if (kind != ':' && kind != '=' && kind != '!') {
+ return false;
+ }
+ }
+ break;
+ case '*':
+ case '+':
+ case '?':
+ case '}':
+ // possessive quantifier
+ if (i + 1 < regex.length() && regex.charAt(i + 1) == '+') {
+ return false;
+ }
+ break;
+ default:
+ break;
+ }
+ }
+ return !inCharClass;
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=EcmaScriptSafeRegexTest`
+Expected: PASS, 7 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java \
+ core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): add ECMAScript-safe regex detection
+
+Decides whether a Java regex can become an HTML5 pattern attribute without
+changing meaning. Allowlist by design: a denylist would violate the
+never-false-reject rule the first time it missed a construct, and a regex the
+browser reads differently is a rejection the user cannot get past.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 4: `HtmlConstraintProvider` and the default implementation
+
+**Files:**
+- Create: `core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java`
+- Create: `core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java`
+- Modify: `core/src/main/resources/struts-beans.xml:146`
+- Test: `core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java`
+
+**Interfaces:**
+- Consumes: `HtmlControlType` (Task 2), `EcmaScriptSafeRegex.isSafe(String)` (Task 3).
+- Produces: `HtmlConstraintProvider.constraintsFor(List validators, HtmlControlType control, Object action)` returning `Map`. Used by Task 7.
+
+**Signature note:** the spec's sketch showed two parameters. The third, `action`, is required — `Validator.getMessage(Object)` needs the action instance to resolve i18n text, and the `data-msg-*` attributes come from it.
+
+- [ ] **Step 1: Write the failing test**
+
+The negative cases carry the weight here; they are what protects the never-false-reject rule.
+
+```java
+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;
+
+public class StrutsHtmlConstraintProviderTest {
+
+ private StrutsHtmlConstraintProvider provider;
+ private Object action;
+
+ @Before
+ public void setUp() {
+ provider = new StrutsHtmlConstraintProvider();
+ action = new ActionSupport();
+ }
+
+ private Map 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() {
+ EmailValidator validator = new EmailValidator();
+ validator.setDefaultMessage("not an email");
+
+ Map result =
+ provider.constraintsFor(singletonList(validator), HtmlControlType.TEXT, action);
+
+ assertThat(result).containsEntry("data-msg-email", "not an email");
+ }
+}
+```
+
+**Note on `required` and `OTHER`:** the test above asserts `OTHER` gets nothing at all, including `required`. That is deliberate — `OTHER` means "we do not know what this control is", and guessing is how false rejections happen.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsHtmlConstraintProviderTest`
+Expected: FAIL — compilation error, `StrutsHtmlConstraintProvider` does not exist.
+
+- [ ] **Step 3: Write the interface**
+
+Create `core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java` with the licence header, then:
+
+```java
+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.
+ *
+ * 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 constraintsFor(List validators, HtmlControlType control, Object action);
+}
+```
+
+- [ ] **Step 4: Write the default implementation**
+
+Create `core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java` with the licence header, then:
+
+**Post-implementation note (added during final-review fixup, not part of the original TDD pass):** the
+listing below is the shipped implementation, not the first draft. Two corrections were made after this
+task's tests originally went green, both discovered in a whole-branch review:
+
+- `addRequired` split into `addRequiredString` (safe on any text-entry or `TEXTAREA` control) and
+ `addRequiredField` (safe only on `RADIO`/`FILE` — see the mapping table in the spec). The original single
+ `addRequired`, gated only on `control != OTHER`, would have emitted `required` on a text input or a select,
+ which false-rejects an empty string the server accepts. `addRequiredField` was briefly dead code in an
+ intermediate commit because no component overrode `getControlType()` to return `RADIO` or `FILE`; that is
+ fixed in Task 6 below, which now includes those two overrides.
+- `addPattern` gained an explicit `EmailValidator`/`CreditCardValidator` exclusion and a `trim` guard, and
+ `addRange`/`addDoubleRange` gained an integral-`min` guard. Each is explained inline below.
+
+```java
+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}.
+ *
+ * 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 never sets or changes
+ * an input's {@code type} — 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 constraintsFor(List validators, HtmlControlType control, Object action) {
+ Map 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 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 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 attributes, HtmlControlType control) {
+ if (control != HtmlControlType.RADIO && control != HtmlControlType.FILE) {
+ return;
+ }
+ attributes.put("required", "required");
+ }
+
+ protected void addLength(Map 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 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 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 would not.
+ Object min = validator.getMin();
+ if (isIntegral(min)) {
+ putIfPresent(attributes, "min", min);
+ }
+ putIfPresent(attributes, "max", validator.getMax());
+ }
+
+ protected void addDoubleRange(Map attributes, DoubleRangeFieldValidator validator, HtmlControlType control) {
+ if (!isNumericRange(control)) {
+ 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. For {@code input type="number"}/{@code "range"},
+ * the step base is the {@code min} attribute's value when present (otherwise 0); a {@code double}
+ * validator with {@code minInclusive=6000.1} would render {@code min="6000.1"}, making the only valid
+ * values 6000.1, 6001.1, 6002.1 … — the browser then rejects {@code 6002}, which
+ * {@code DoubleRangeFieldValidator} accepts server-side.
+ */
+ 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);
+ }
+
+ protected void addMessage(Map 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 attributes, String name, Object value) {
+ if (value != null) {
+ attributes.put(name, String.valueOf(value));
+ }
+ }
+}
+```
+
+**Scope decision, made explicit:** `DateRangeFieldValidator` extends `RangeValidatorSupport`, so it reaches `addRange`, where `isNumericRange` rejects it and it emits nothing. Temporal `min`/`max` need per-control ISO formatting (`date` wants `yyyy-MM-dd`, `week` wants `2026-W12`, and so on) and are **deliberately not implemented here**. `HtmlControlType.supportsRange()` already covers the temporal types so the enum needs no change when this lands; file it as a follow-up rather than guessing at the formats now.
+
+- [ ] **Step 5: Register the bean**
+
+In `core/src/main/resources/struts-beans.xml`, immediately after the `UrlRenderer` bean (around line 146):
+
+```xml
+
+```
+
+Register it **once**, under a single type. Registering a bean under two types builds two instances.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsHtmlConstraintProviderTest`
+Expected: PASS, 14 tests.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java \
+ core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java \
+ core/src/main/resources/struts-beans.xml \
+ core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
+git diff --cached --name-only
+git commit -m "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 "
+```
+
+---
+
+### Task 5: `Form.getFieldValidators` with per-render memoisation
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/components/Form.java`
+- Test: `core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java`
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `Form.getFieldValidators(String name)` returning `List`. Used by Task 7.
+
+`Form.getValidators(String)` re-runs the action-mapping lookup and `actionValidatorManager.getValidators(...)` on **every** call, so a twenty-field form would do twenty full resolutions. The new method resolves once per form render.
+
+**Memoisation location:** on private fields of the `Form` component, **not** on the attributes map as the spec sketch said. A `Form` component is constructed per render by `ComponentTagSupport`, so a field is naturally request-scoped, and the attributes map is exposed to templates and should not carry private bookkeeping.
+
+Leave `getValidators(String)` exactly as it is. It is deprecated and dies with the JavaScript validator in 8.0.0; the small duplication dies with it.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+import org.apache.struts2.views.jsp.ui.FormTag;
+
+import java.util.HashMap;
+import java.util.List;
+
+public class FormFieldValidatorsTest extends AbstractUITagTest {
+
+ public void testFindsTheFieldsValidators() throws Exception {
+ Form form = formForDoubleValidationAction();
+
+ List validators = form.getFieldValidators("myUpDownSelectTag");
+
+ assertEquals(1, validators.size());
+ assertEquals("double", validators.get(0).getValidatorType());
+ }
+
+ public void testReturnsEmptyForAnUnvalidatedField() throws Exception {
+ Form form = formForDoubleValidationAction();
+
+ assertTrue(form.getFieldValidators("noSuchField").isEmpty());
+ }
+
+ public void testResolvesTheActionsValidatorsOnlyOnceAcrossFields() throws Exception {
+ Form form = formForDoubleValidationAction();
+
+ ActionValidatorManager manager = mock(ActionValidatorManager.class);
+ when(manager.getValidators(any(), any(), any())).thenReturn(Collections.emptyList());
+ form.setActionValidatorManager(manager);
+
+ form.getFieldValidators("myUpDownSelectTag");
+ form.getFieldValidators("someOtherField");
+
+ verify(manager, times(1)).getValidators(any(), any(), any());
+ }
+
+ private Form formForDoubleValidationAction() throws Exception {
+ FormTag tag = new FormTag();
+ tag.setPageContext(pageContext);
+ tag.setName("myForm");
+ tag.setAction("doubleValidationAction");
+ tag.setNamespace("");
+ tag.doStartTag();
+ return (Form) tag.getComponent();
+ }
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ initDispatcher(new HashMap() {{
+ put("configProviders", TestConfigurationProvider.class.getName());
+ }});
+ createMocks();
+ }
+}
+```
+
+**Harness trap, part two:** `createMocks()` never calls `setConfig` on the `MockActionProxy` it builds, so
+`AnnotationActionValidatorManager.buildValidatorKey` dereferences a null `ActionConfig` and NPEs. The test's
+`setUp` also needs `((MockActionProxy) actionProxy).setConfig(configuration.getRuntimeConfiguration()
+.getActionConfig("", "doubleValidationAction"))`. This is why `FormTagTest` carries its own
+`prepareMockInvocation()` helper.
+
+**Do not write a memoisation test that only compares result sizes** — resolution is deterministic, so such a
+test passes identically against an implementation with no cache at all. Assert the *number of resolutions*
+with a mocked `ActionValidatorManager`, across two different field names.
+
+**Harness trap:** without `initDispatcher(configProviders = TestConfigurationProvider)` *and* `createMocks()`, the action config is not present and validator resolution silently returns nothing — the test would pass or fail for entirely the wrong reason. `DoubleValidationAction-validation.xml` already exists at `core/src/test/resources/org/apache/struts2/views/jsp/ui/` and declares a `double` validator for `myUpDownSelectTag`.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=FormFieldValidatorsTest`
+Expected: FAIL — compilation error, `getFieldValidators` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+In `Form.java`, add two fields beside the existing ones (near `actionValidatorManager`, around line 120):
+
+```java
+ private List cachedActionValidators;
+ private String cachedActionName;
+ private boolean actionValidatorsResolved;
+```
+
+Then add the method next to `getValidators`:
+
+```java
+ /**
+ * Returns the validators declared for a single field, resolving the action's validator list at
+ * most once per form render.
+ *
+ * @since 7.4.0
+ */
+ public List getFieldValidators(String name) {
+ resolveActionValidators();
+ if (cachedActionValidators.isEmpty()) {
+ return Collections.emptyList();
+ }
+ Class actionClass = (Class) getAttributes().get("actionClass");
+ List validators = new ArrayList<>();
+ findFieldValidators(name, actionClass, cachedActionName, cachedActionValidators, validators, "");
+ return validators;
+ }
+
+ private void resolveActionValidators() {
+ if (actionValidatorsResolved) {
+ return;
+ }
+ actionValidatorsResolved = true;
+ cachedActionValidators = Collections.emptyList();
+
+ Class actionClass = (Class) getAttributes().get("actionClass");
+ if (actionClass == null) {
+ return;
+ }
+ ActionMapping mapping = actionMapper.getMappingFromActionName(findString(action));
+ if (mapping == null) {
+ mapping = actionMapper.getMappingFromActionName((String) getAttributes().get("actionName"));
+ }
+ if (mapping == null) {
+ return;
+ }
+ cachedActionName = mapping.getName();
+ String methodName = isValidateAnnotatedMethodOnly(cachedActionName) ? mapping.getMethod() : null;
+ cachedActionValidators =
+ actionValidatorManager.getValidators(actionClass, cachedActionName, methodName);
+ }
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=FormFieldValidatorsTest`
+Expected: PASS, 3 tests.
+
+- [ ] **Step 5: Verify nothing regressed**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=FormTagTest`
+Expected: PASS. `getValidators` was not touched, so all four `validateForm_` golden files must still match.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/Form.java \
+ core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): add Form.getFieldValidators with per-render caching
+
+getValidators re-runs the action-mapping lookup and the validator-manager
+resolution on every call, so a twenty-field form would do twenty of them. The
+new method resolves once per form render and filters per field.
+
+Memoised on component fields rather than the attributes map: a Form component
+is built per render, and the attributes map is exposed to templates.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 6: `getControlType()` on the component hierarchy
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/components/UIBean.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/TextField.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/Password.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/TextArea.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/Select.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/Radio.java` (added as a fix — see the correction note below)
+- Modify: `core/src/main/java/org/apache/struts2/components/File.java` (added as a fix — see the correction note below)
+- Test: `core/src/test/java/org/apache/struts2/components/ControlTypeTest.java`
+
+**Interfaces:**
+- Consumes: `HtmlControlType` (Task 2).
+- Produces: `protected HtmlControlType UIBean.getControlType()`. Used by Task 7.
+
+`attributes.type` is set by `TextField.evaluateExtraParams` (`TextField.java:91`) and by nothing else on the input path — `TextArea`, `Select`, `Checkbox`, `File`, `Hidden` and `Radio` have no `type` attribute at all. So the control type needs a component hook.
+
+**Post-implementation correction (added during final-review fixup):** the steps below, as originally
+written, gave `Checkbox`, `Radio`, `File` and `Hidden` **no** override, on the theory that all four should
+fall through to `OTHER`. That was wrong for two of them. `RADIO` and `FILE` are the only controls where an
+unselected/empty submission omits the parameter entirely rather than submitting an empty value, so they are
+the only two where `required` agrees with the server (see the mapping table in the spec) — without their own
+`getControlType()` override they fall through to `OTHER`, and `addRequiredField` in `StrutsHtmlConstraintProvider`
+becomes unreachable dead code. `Radio` and `File` were given overrides as a fix, making it six overrides
+total, not four. `Checkbox` and `Hidden` are unaffected by this correction and still deliberately get no
+override: `CheckboxInterceptor` substitutes `"false"` for an unticked box, so the field is never missing
+server-side and a browser `required` there would false-reject. `ComboBox` extends `TextField` and correctly
+inherits `TEXT`.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+
+public class ControlTypeTest extends AbstractUITagTest {
+
+ public void testTextFieldDefaultsToText() {
+ TextField textField = new TextField(stack, request, response);
+ assertEquals(HtmlControlType.TEXT, textField.getControlType());
+ }
+
+ public void testTextFieldHonoursAnExplicitType() {
+ TextField textField = new TextField(stack, request, response);
+ textField.addParameter("type", "number");
+ assertEquals(HtmlControlType.NUMBER, textField.getControlType());
+ }
+
+ public void testTextFieldFallsBackForAnUnknownType() {
+ TextField textField = new TextField(stack, request, response);
+ textField.addParameter("type", "supercolor");
+ assertEquals(HtmlControlType.OTHER, textField.getControlType());
+ }
+
+ public void testPasswordIsAlwaysPassword() {
+ Password password = new Password(stack, request, response);
+ assertEquals(HtmlControlType.PASSWORD, password.getControlType());
+ }
+
+ public void testTextAreaIsTextarea() {
+ TextArea textArea = new TextArea(stack, request, response);
+ assertEquals(HtmlControlType.TEXTAREA, textArea.getControlType());
+ }
+
+ public void testSelectIsSelect() {
+ Select select = new Select(stack, request, response);
+ assertEquals(HtmlControlType.SELECT, select.getControlType());
+ }
+
+ public void testControlsWithoutAnOverrideAreUnknown() {
+ assertEquals(HtmlControlType.OTHER, new Checkbox(stack, request, response).getControlType());
+ assertEquals(HtmlControlType.OTHER, new Hidden(stack, request, response).getControlType());
+ assertEquals(HtmlControlType.OTHER, new File(stack, request, response).getControlType());
+ }
+}
+```
+
+`getControlType()` is `protected`, and this test lives in the same package, so it is directly callable.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ControlTypeTest`
+Expected: FAIL — compilation error, `getControlType` does not exist.
+
+- [ ] **Step 3: Add the base method to `UIBean`**
+
+Place it next to the other `protected` helpers, above `evaluateExtraParams()`:
+
+```java
+ /**
+ * The kind of HTML control this component renders, used to decide which HTML5 constraint
+ * attributes are legal on it. Defaults to {@link HtmlControlType#OTHER}, which supports no
+ * constraints — so a component that does not override this emits none.
+ *
+ * @since 7.4.0
+ */
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.OTHER;
+ }
+```
+
+- [ ] **Step 4: Add the overrides** (originally four; `Radio` and `File` were added as a fix — see the
+ correction note above)
+
+`TextField.java` — reads the attribute set by its own `evaluateExtraParams`, defaulting to `TEXT` to match `text.ftl`'s `attributes.type!"text"`:
+
+```java
+ @Override
+ protected HtmlControlType getControlType() {
+ Object type = getAttributes().get("type");
+ return type == null ? HtmlControlType.TEXT : HtmlControlType.from(String.valueOf(type));
+ }
+```
+
+`Password.java` — it extends `TextField`, but its template hardcodes the type:
+
+```java
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.PASSWORD;
+ }
+```
+
+`TextArea.java`:
+
+```java
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.TEXTAREA;
+ }
+```
+
+`Select.java`:
+
+```java
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.SELECT;
+ }
+```
+
+`Radio.java` and `File.java` — added as a fix; without these, `addRequiredField` in
+`StrutsHtmlConstraintProvider` has no control type it can ever match, making it dead code:
+
+```java
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.RADIO;
+ }
+```
+
+```java
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.FILE;
+ }
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ControlTypeTest`
+Expected: PASS, 7 tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/UIBean.java \
+ core/src/main/java/org/apache/struts2/components/TextField.java \
+ core/src/main/java/org/apache/struts2/components/Password.java \
+ core/src/main/java/org/apache/struts2/components/TextArea.java \
+ core/src/main/java/org/apache/struts2/components/Select.java \
+ core/src/main/java/org/apache/struts2/components/Radio.java \
+ core/src/main/java/org/apache/struts2/components/File.java \
+ core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): resolve the HTML control type per component
+
+attributes.type is set by TextField and nothing else on the input path, so the
+control type cannot come from the attribute map alone. Adds getControlType()
+with six overrides; Checkbox and Hidden fall through to OTHER, which emits
+nothing and is correct for both (CheckboxInterceptor substitutes false for an
+unticked box, so required there would false-reject). Radio and File get their
+own RADIO/FILE overrides, since they are the only controls where required
+agrees with the server.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 7: The constant and the `UIBean` wiring
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java:204`
+- Modify: `core/src/main/resources/org/apache/struts2/default.properties:167`
+- Modify: `core/src/main/java/org/apache/struts2/components/UIBean.java`
+- Test: `core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java`
+
+**Interfaces:**
+- Consumes: `HtmlConstraintProvider` (Task 4), `Form.getFieldValidators` (Task 5), `getControlType()` (Task 6).
+- Produces: the `constraints` attribute on the component's attribute map, a `Map`. Consumed by Task 8's template.
+
+**Hook placement — this is the part that is easy to get wrong.** `evaluateParams()` resolves `final Form form = (Form) findAncestor(Form.class)` at `UIBean.java:824` and appends to `tagNames` just below. **The hook must not go there.** `evaluateExtraParams()` is the *last* statement of `evaluateParams()` (`UIBean.java:905`), and that is where `TextField` sets `attributes.type` — so at the `tagNames` block no text field has a resolved type and every one of them would look like `OTHER`. The hook goes at the very end of the method. The `form` local is method-scoped and still in scope there.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+import org.apache.struts2.views.jsp.ui.FormTag;
+import org.apache.struts2.views.jsp.ui.TextFieldTag;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class ConstraintAttributesTest extends AbstractUITagTest {
+
+ public void testNoConstraintsWhenTheConstantIsOff() throws Exception {
+ initDispatcherWith("false");
+
+ assertNull(renderFieldAndReturnConstraints());
+ }
+
+ public void testConstraintsWhenTheConstantIsOn() throws Exception {
+ initDispatcherWith("true");
+
+ Map constraints = renderFieldAndReturnConstraints();
+ assertNotNull("expected constraints to be populated", constraints);
+ assertEquals("3", constraints.get("minlength"));
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map renderFieldAndReturnConstraints() throws Exception {
+ FormTag form = new FormTag();
+ form.setPageContext(pageContext);
+ form.setAction("constraintAction");
+ form.setNamespace("");
+ form.doStartTag();
+
+ TextFieldTag field = new TextFieldTag();
+ field.setPageContext(pageContext);
+ field.setName("username");
+ field.doStartTag();
+
+ Map attributes =
+ ((UIBean) field.getComponent()).getAttributes();
+
+ field.doEndTag();
+ form.doEndTag();
+
+ return (Map) attributes.get("constraints");
+ }
+
+ private void initDispatcherWith(String constraintsEnabled) throws Exception {
+ initDispatcher(new HashMap() {{
+ put("configProviders", TestConfigurationProvider.class.getName());
+ put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled);
+ }});
+ createMocks();
+ }
+}
+```
+
+This test needs an action named `constraintAction` with a `stringlength` validator carrying `trim="false"` and `minLength=3` on a `username` field. Create both:
+
+`core/src/test/java/org/apache/struts2/components/ConstraintAction.java`:
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
+
+public class ConstraintAction extends ActionSupport {
+
+ private String username;
+
+ public String getUsername() {
+ return username;
+ }
+
+ @StrutsParameter
+ public void setUsername(String username) {
+ this.username = username;
+ }
+}
+```
+
+`core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml` (copy the licence header and DOCTYPE verbatim from `core/src/test/resources/org/apache/struts2/views/jsp/ui/DoubleValidationAction-validation.xml`):
+
+```xml
+
+
+
+ false
+ 3
+ username must be at least ${minLength} characters
+
+
+
+```
+
+Then register `constraintAction` in `core/src/test/java/org/apache/struts2/TestConfigurationProvider.java`. Add the config beside the existing `doubleValidationActionConfig` (around line 90) — it must carry the `ValidationInterceptor` mapping, or `evaluateClientSideJsEnablement` finds no interceptor and the action looks unvalidated:
+
+```java
+ ActionConfig constraintActionConfig = new ActionConfig.Builder("", "constraintAction", ConstraintAction.class.getName())
+ .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName())
+ .addParam("location", "success.jsp")
+ .build())
+ .addInterceptor(new InterceptorMapping("validation", validationInterceptor))
+ .build();
+```
+
+and register it in the `defaultPackageConfig` builder (around line 121), next to the `doubleValidationAction` line:
+
+```java
+ .addActionConfig("constraintAction", constraintActionConfig)
+```
+
+Add `import org.apache.struts2.components.ConstraintAction;` at the top.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ConstraintAttributesTest`
+Expected: FAIL — compilation error, `STRUTS_UI_HTML5_CONSTRAINTS` does not exist.
+
+- [ ] **Step 3: Add the constant**
+
+In `StrutsConstants.java`, after `STRUTS_UI_STATIC_CONTENT_PATH` (around line 204):
+
+```java
+ /**
+ * Whether the html5 theme emits HTML5 constraint attributes derived from the action's validators.
+ * Defaults to false in 7.4.0; the default becomes true in 8.0.0.
+ *
+ * @since 7.4.0
+ */
+ public static final String STRUTS_UI_HTML5_CONSTRAINTS = "struts.ui.html5.constraints";
+```
+
+In `default.properties`, in the "Standard UI theme" block after `struts.ui.templateSuffix=ftl`:
+
+```properties
+### Whether the html5 theme emits HTML5 constraint attributes (required, minlength,
+### maxlength, pattern, min, max) derived from the action's validators.
+### Defaults to false so existing html5-theme forms render unchanged; becomes true in 8.0.0.
+struts.ui.html5.constraints=false
+```
+
+- [ ] **Step 4: Wire `UIBean`**
+
+Add the field and injection setters alongside the existing ones (near `setCspNonceReader`, around line 559):
+
+```java
+ protected HtmlConstraintProvider htmlConstraintProvider;
+ protected boolean html5ConstraintsEnabled;
+
+ @Inject
+ public void setHtmlConstraintProvider(HtmlConstraintProvider htmlConstraintProvider) {
+ this.htmlConstraintProvider = htmlConstraintProvider;
+ }
+
+ @Inject(value = StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, required = false)
+ public void setHtml5ConstraintsEnabled(String html5ConstraintsEnabled) {
+ this.html5ConstraintsEnabled = BooleanUtils.toBoolean(html5ConstraintsEnabled);
+ }
+```
+
+Then, at the **end** of `evaluateParams()`, after the existing `evaluateExtraParams();` call at line 905:
+
+```java
+ 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.
+ *
+ * @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;
+ }
+ Map constraints = htmlConstraintProvider.constraintsFor(
+ form.getFieldValidators(fieldName), getControlType(), stack.peek());
+ if (!constraints.isEmpty()) {
+ addParameter("constraints", constraints);
+ }
+ }
+```
+
+Add `import org.apache.commons.lang3.BooleanUtils;` if it is not already present.
+
+Gating on `html5ConstraintsEnabled` first means the cost is exactly zero when the feature is off.
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ConstraintAttributesTest`
+Expected: PASS, 2 tests.
+
+- [ ] **Step 6: Verify nothing regressed**
+
+Run: `mvn test -DskipAssembly -pl core`
+Expected: PASS. The constant defaults to `false`, so every existing golden file must be unchanged. If `DateTest.testJavaSqlDate` fails alone, that is the known WW-5686 flake — rerun it on its own to confirm.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/StrutsConstants.java \
+ core/src/main/resources/org/apache/struts2/default.properties \
+ core/src/main/java/org/apache/struts2/components/UIBean.java \
+ core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java \
+ core/src/test/java/org/apache/struts2/components/ConstraintAction.java \
+ core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml \
+ core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): derive constraint attributes during tag evaluation
+
+Adds struts.ui.html5.constraints, default false, and wires UIBean to the
+constraint provider behind it.
+
+The hook sits at the end of evaluateParams rather than beside the tagNames
+block, because evaluateExtraParams is where TextField resolves attributes.type
+and it runs last; hooking earlier would make every text field look like OTHER.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 8: Render the attributes from the html5 theme
+
+**Files:**
+- Create: `core/src/main/resources/template/html5/constraints.ftl`
+- Modify: `core/src/main/resources/template/html5/common-attributes.ftl`
+- Test: `core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java`
+
+**Interfaces:**
+- Consumes: the `constraints` attribute produced by Task 7.
+- Produces: rendered HTML. Nothing downstream depends on it.
+
+Including from `common-attributes.ftl` means every html5 input picks the attributes up without per-template edits.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.views.jsp.ui;
+
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+
+import java.util.HashMap;
+
+public class Html5ConstraintRenderingTest extends AbstractUITagTest {
+
+ public void testRendersConstraintAttributes() throws Exception {
+ String output = render("true");
+
+ assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\""));
+ }
+
+ public void testRendersNothingWhenTheConstantIsOff() throws Exception {
+ String output = render("false");
+
+ assertFalse("expected no minlength in: " + output, output.contains("minlength="));
+ }
+
+ public void testRequiredLabelDoesNotBecomeARequiredAttribute() throws Exception {
+ FormTag form = new FormTag();
+ form.setPageContext(pageContext);
+ form.setTheme("html5");
+ form.setAction("constraintAction");
+ form.setNamespace("");
+ form.doStartTag();
+
+ TextFieldTag field = new TextFieldTag();
+ field.setPageContext(pageContext);
+ field.setTheme("html5");
+ field.setName("noValidatorHere");
+ field.setRequiredLabel("true");
+ field.doStartTag();
+ field.doEndTag();
+ form.doEndTag();
+
+ String output = writer.toString();
+ assertFalse("requiredLabel draws an asterisk; it must never emit a required attribute: " + output,
+ output.contains("required=\"required\""));
+ }
+
+ private String render(String constraintsEnabled) throws Exception {
+ initDispatcher(new HashMap() {{
+ put("configProviders", TestConfigurationProvider.class.getName());
+ put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled);
+ }});
+ createMocks();
+
+ FormTag form = new FormTag();
+ form.setPageContext(pageContext);
+ form.setTheme("html5");
+ form.setAction("constraintAction");
+ form.setNamespace("");
+ form.doStartTag();
+
+ TextFieldTag field = new TextFieldTag();
+ field.setPageContext(pageContext);
+ field.setTheme("html5");
+ field.setName("username");
+ field.doStartTag();
+ field.doEndTag();
+ form.doEndTag();
+
+ return writer.toString();
+ }
+}
+```
+
+The third test is the one that matters most: `requiredLabel` is the visual asterisk and must never become a `required` attribute. Conflating them is the most likely regression in this whole change.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=Html5ConstraintRenderingTest`
+Expected: FAIL — `testRendersConstraintAttributes` finds no `minlength`, because nothing renders the map yet.
+
+- [ ] **Step 3: Create the template**
+
+`core/src/main/resources/template/html5/constraints.ftl`, with the same `<#--` licence header used by the neighbouring html5 templates, then:
+
+```
+<#if attributes.constraints??><#list attributes.constraints as attributeName, attributeValue> ${attributeName}="${attributeValue}"<#rt/>#list>#if>
+```
+
+**Do not add a `?html` builtin — it is a parse error here.** `FreemarkerManager` sets
+`ENABLE_IF_DEFAULT_AUTO_ESCAPING_POLICY` with `HTMLOutputFormat` (`FreemarkerManager.java:354-355`), so values
+are already HTML-escaped by configuration and FreeMarker rejects `?html` as a double-escape. Zero templates in
+this repo use it. The escaping is still load-bearing — `data-msg-*` values are OGNL-interpolated and can carry
+user-submitted content — so never disable auto-escaping for this template.
+
+- [ ] **Step 4: Include it**
+
+Append to `core/src/main/resources/template/html5/common-attributes.ftl`, after the existing `accesskey` block:
+
+```
+<#include "/${attributes.templateDir}/${attributes.expandTheme}/constraints.ftl" /><#rt/>
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=Html5ConstraintRenderingTest`
+Expected: PASS, 3 tests.
+
+- [ ] **Step 6: Verify the whole suite**
+
+Run: `mvn test -DskipAssembly -pl core`
+Expected: PASS, including every existing html5 golden file — the constant is off by default, so `Formtag-1-html5.txt` and friends must be byte-identical.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add core/src/main/resources/template/html5/constraints.ftl \
+ core/src/main/resources/template/html5/common-attributes.ftl \
+ core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(html5): render derived constraint attributes
+
+Included from common-attributes.ftl so every html5 input picks the attributes
+up without per-template edits. Values are HTML-escaped: pattern and the
+data-msg-* text are author-controlled and land inside an attribute.
+
+Covers the regression that matters most - requiredLabel draws an asterisk and
+must never emit a required attribute.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 9: Documentation (`struts-site` repository)
+
+**This task is in a different repository:** `~/Projects/Apache/struts-site`. It needs its own branch, its own commit, and its own PR. Docs changes there take conventional-commit form with no ticket prefix.
+
+**Files:**
+- Modify: `source/core-developers/client-side-validation.md`
+- Delete: `source/core-developers/pure-java-script-client-side-validation.md`
+- Modify: `source/core-developers/client-validation-example.md`
+- Modify: `source/tag-developers/form-tag.md`
+
+- [ ] **Step 1: Rewrite the client-side validation page**
+
+Rewrite `client-side-validation.md` around the html5 theme. It must carry:
+
+- the `struts.ui.html5.constraints` constant, its `false` default in 7.4.0 and the 8.0.0 flip;
+- the full mapping table from the spec, including the `trim="false"` and `caseSensitive="true"` conditions;
+- the never-change-the-type rule, stated as the reason `type="email"`, `type="url"` and `type="number"` are never set by Struts, with the `1234,50` comma-decimal example;
+- the `data-msg-*` attributes, and that Struts ships nothing that consumes them;
+- that `requiredLabel` is unrelated to the `required` attribute;
+- a deprecation notice for the JavaScript validator pointing at WW-5694 and WW-5696.
+
+Its existing "Client Side Validation Types" table links to the pure-JavaScript page; replace that section.
+
+- [ ] **Step 2: Delete the pure-JavaScript page and fix the stale i18n claim**
+
+Delete `pure-java-script-client-side-validation.md`. Before deleting, note that it claims errors are reported "not the internationalized version that the server-side might be aware of" — **that is wrong**, `ValidatorSupport.getMessage` resolves through `DelegatingValidatorContext` and `textProviderFactory`. Do not carry the claim into the rewritten page.
+
+Grep for inbound links and repoint them:
+
+```bash
+cd ~/Projects/Apache/struts-site
+grep -rn "pure-java-script-client-side-validation" source/
+```
+
+- [ ] **Step 3: Mark the example page deprecated**
+
+Add a deprecation banner at the top of `client-validation-example.md` pointing at the html5 theme.
+
+- [ ] **Step 4: Note the deprecated attribute**
+
+In `tag-developers/form-tag.md`, mark `validate` deprecated since 7.4.0, removed in 8.0.0.
+
+- [ ] **Step 5: Leave the html5 theme's "since" version alone**
+
+`tag-developers/html5-theme.md` says "Available since Struts 7.2.0". **That is correct** — `git tag --contains e24d2f2d3` returns `STRUTS_7_2_0`. WW-5444's fix version of 7.2.1 is the wrong record. Do not "fix" the docs to match Jira; the Jira ticket is what needs correcting.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ~/Projects/Apache/struts-site
+git checkout -b docs/html5-constraint-validation
+git add source/core-developers/client-side-validation.md \
+ source/core-developers/client-validation-example.md \
+ source/tag-developers/form-tag.md
+git rm source/core-developers/pure-java-script-client-side-validation.md
+git diff --cached --name-only
+git commit -m "docs: document html5 constraint validation, deprecate the JS validator
+
+Rewrites the client-side validation page around the html5 theme's constraint
+attributes and removes the pure-JavaScript page.
+
+Drops that page's claim that client-side messages are not internationalized:
+ValidatorSupport.getMessage resolves through DelegatingValidatorContext and
+textProviderFactory, so they always were.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+## Follow-ups (file after this plan lands, do not do them here)
+
+- **Temporal `min`/`max`.** `DateRangeFieldValidator` currently emits nothing. Needs per-control ISO formatting (`date` → `yyyy-MM-dd`, `month` → `yyyy-MM`, `week` → `yyyy-'W'ww`, `time` → `HH:mm`). `HtmlControlType.supportsRange()` already covers the temporal types, so no enum change is needed.
+- **WW-5444's fix version** says 7.2.1; the html5 theme actually shipped in 7.2.0. Correct the ticket.
+- **The regex allowlist will be too strict for some real applications.** Expect tuning after the first release; the swappable provider is the escape hatch in the meantime.
+- **WW-5696** — the 8.0.0 removal. Already filed, out of scope here.
diff --git a/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md b/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md
new file mode 100644
index 0000000000..1de76384e0
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md
@@ -0,0 +1,422 @@
+# HTML5 constraint validation, and retiring the JavaScript client-side validator
+
+**Tickets:** not yet filed — three proposed, see "Ticket structure"
+**Supersedes:** [WW-2975](https://issues.apache.org/jira/browse/WW-2975) (to be closed Won't Fix)
+**Target:** 7.4.0 (deprecate + new feature), 8.0.0 (remove)
+**Date:** 2026-08-24
+**Status:** Design approved, pending implementation plan
+
+**Plan scope:** the implementation plan drawn from this spec covers the **7.4.0 work only** — the deprecation
+and the new constraint feature. The 8.0.0 removal is specified here so the deprecation is written against a
+known endpoint, but it is a separate ticket, a separate release and a separate plan.
+
+## Problem
+
+`xhtml/form-close-validate.ftl` generates a `validateForm_()` function by iterating
+`attributes.tagNames`. That list is seeded by `Form.evaluateExtraParams` (`Form.java:207`) and appended to
+by `UIBean.evaluateParams` (`UIBean.java:834`) — but **only** when `findAncestor(Form.class)` finds the form
+on the component stack.
+
+Any input that reaches the form's markup by another route is therefore invisible to client-side validation:
+raw HTML, a custom tag, an included fragment, or a component handed pre-rendered field markup. The generated
+function is then an empty shell:
+
+```js
+function validateForm_doubleValidationAction() {
+ ...
+ var errors = false;
+ var continueValidation = true;
+
+ return !errors;
+}
+```
+
+This is WW-2975, reported 2009-01-30 against 2.1.6 and reproduced unchanged on `main` (7.4.0-SNAPSHOT) on
+2026-08-24. The reproduction renders a form whose action declares a `double` validator for
+`myUpDownSelectTag`: a nested `` produces the `if (form.elements['myUpDownSelectTag'])`
+block, while an identical raw `` produces nothing at all.
+
+The ticket's second complaint is also live, at LOW confidence (the repo has **no** JavaScript test
+infrastructure — no `package.json`, nothing). `validation.js` `addErrorXHTML` walks `row.parentNode` up
+until it finds a `TR`. A field with no `TR` ancestor sends that walk off the top of the document, and the
+handler is `catch (err) { alert(err) }` — so the user gets a raw JavaScript error instead of a validation
+message.
+
+### Why this is not being fixed in place
+
+The feature is legacy on every axis. It exists only in `xhtml` (the default theme) and `css_xhtml`
+(`parent = xhtml`); the `html5` theme has `parent = simple` and never had it. It supports eight validators.
+It reports errors by inserting `