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..2614c5c571
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java
@@ -0,0 +1,124 @@
+/*
+ * 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.
+ *
+ * {@code \b} and {@code \B} are absent for a sharper reason: their meaning is not even stable
+ * across the JDKs Struts supports. Up to Java 18 the boundary was decided by
+ * {@code Character.isLetterOrDigit}, making it Unicode-aware while {@code \w} stayed ASCII;
+ * JDK 19 resolved that inconsistency. So {@code ^\bäiti\b$} matches {@code äiti} on Java 17 and
+ * not on Java 21, while ECMAScript — whose boundary is always ASCII-word based — rejects it in
+ * every browser. On the Java 17 baseline that is a false reject, and no version check could fix
+ * it: one {@code validation.xml} would have to mean two different things depending on the JVM.
+ */
+ private static final String ALLOWED_ESCAPES = "dDwWnrtf\\.*+?()[]{}|^$/-";
+
+ 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..42d7cbbcd0 100644
--- a/core/src/main/java/org/apache/struts2/components/UIBean.java
+++ b/core/src/main/java/org/apache/struts2/components/UIBean.java
@@ -25,9 +25,12 @@
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;
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionInvocation;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.StrutsException;
import org.apache.struts2.components.template.Template;
@@ -531,6 +534,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 +567,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 +919,108 @@ 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
+ * failing 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. Swallowing the failure is only safe because
+ * {@link #restoreStackDepth(int)} undoes whatever that failure left on the value stack.
+ *
+ * @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;
+ }
+ int stackDepth = stack.getRoot().size();
+ try {
+ Map constraints = htmlConstraintProvider.constraintsFor(
+ form.getFieldValidators(fieldName), getControlType(), resolveAction());
+ 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);
+ } finally {
+ restoreStackDepth(stackDepth);
+ }
+ }
+
+ /**
+ * The object server-side validation runs against, which is what the derived {@code data-msg-*}
+ * messages must be resolved against too: {@code ValidatorSupport.getMessage} builds a
+ * {@code DelegatingValidatorContext} from it, and that decides which resource bundle the message
+ * key is looked up in.
+ *
+ * Deliberately not {@code stack.peek()}. The top of the stack is not the action whenever
+ * something has been pushed over it — {@code ModelDrivenInterceptor} pushes the model, and an
+ * {@code } wrapping the field pushes the current element — so peeking would resolve
+ * messages against a model or a list element while {@code ValidationInterceptor} validated the
+ * action.
+ *
+ * @return the action, or null when rendering outside action scope, in which case the provider
+ * simply derives no message attributes
+ */
+ private Object resolveAction() {
+ ActionInvocation invocation = ActionContext.of(stack.getContext()).getActionInvocation();
+ return invocation == null ? null : invocation.getAction();
+ }
+
+ /**
+ * Pops whatever constraint derivation left behind. {@code ValidatorSupport.getMessage} pushes the
+ * action and the validator onto this same request-scoped stack — {@code
+ * DefaultActionValidatorManager.getValidators} hands it {@code ActionContext.getValueStack()} —
+ * and its matching pops are not in a {@code finally}. A message that fails to resolve (a bad
+ * {@code MessageFormat} pattern, an unresolvable {@code ${}}) would therefore leave frames on the
+ * stack, and because the catch above deliberately swallows the failure, every tag rendered after
+ * this one would silently resolve its OGNL against the wrong root.
+ */
+ private void restoreStackDepth(int depth) {
+ while (stack.getRoot().size() > depth) {
+ stack.pop();
+ }
+ }
+
+ /**
+ * 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 +1086,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..7a76f1d7be
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
@@ -0,0 +1,170 @@
+/*
+ * 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.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class ConstraintAttributesTest extends AbstractUITagTest {
+
+ private FormTag form;
+
+ 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"));
+ }
+
+ /**
+ * The action handed to the provider must be the one server-side validation ran against —
+ * {@code ValidationInterceptor} validates {@code invocation.getAction()} — because
+ * {@code ValidatorSupport.getMessage} builds its {@code DelegatingValidatorContext} from that
+ * object, and the context decides which resource bundle a {@code data-msg-*} key resolves in.
+ * The top of the value stack is not that action whenever something has been pushed over it:
+ * {@code ModelDrivenInterceptor} pushes the model, an {@code } around the field
+ * pushes the current element. The marker pushed here stands in for both.
+ */
+ public void testActionComesFromTheInvocationNotTheTopOfTheStack() throws Exception {
+ initDispatcherWith("true");
+
+ TextFieldTag field = startField(null);
+ List