This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new 839bfc9df WW-5740 fix(core): resolve visitor-nested constraint
messages like validation does (#1937)
839bfc9df is described below
commit 839bfc9dfdb41f0f4b5f5779834f619db8fd974c
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Sep 14 12:19:21 2026 +0200
WW-5740 fix(core): resolve visitor-nested constraint messages like
validation does (#1937)
* WW-5740 fix(core): resolve visitor-nested constraint messages like
validation does
A field reached through a visitor validator had its data-msg-* text
resolved through DelegatingValidatorContext(action) — the action's
bundles only — while VisitorFieldValidator.validateObject validates it
with a composite provider over the visited class and the action, and
pushes the visited instance for ${...}. A <message key=.../> living in
the visited class's own .properties rendered as the raw key.
Form now gives each unwrapped visitor validator the same composite
context at unwrap time (class-based, so a form rendered before the
visited object exists still finds the bundle) and exposes
getValidatedObject(name); UIBean hands the provider the visited instance
when it exists, so ${...} and messageParams read it as they do during
validation. HtmlConstraintProvider keeps its signature; the third
parameter's javadoc now says what object it is.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5740 fix(core): carry the whole visited chain for nested visitors
Review of the first cut found two ways rendering still diverged from
VisitorFieldValidator: a doubly-nested visitor (user.address.street)
skipped the intermediate level — validation chains Address, User and
action bundles and has user and address on the stack — and a field
claimed by two appendPrefix="false" visitors, or by a visitor-nested
validator next to a direct one, handed the provider whichever object
was recorded last.
The wrapper now carries every visited class and path outermost first,
the composite provider folds each level innermost first, UIBean pushes
the intermediate objects (restoreStackDepth pops them), and a field
gets a validated object only when exactly one visited path claims it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5740 fix(core): read the visited object off the action, not the top of
the stack
Second review pass: findValue(path) at render time searched the whole
stack, so an iterator or push frame exposing the same property shadowed
the action's object that validation uses; the action is now pushed for
the lookup. And a partial chain (user set, user.address null) handed the
provider the User for an Address message — the innermost object is
handed over only when the whole chain exists, the levels that do exist
are still pushed for ${...}.
Not mirrored, on purpose: the visitor's own <message> that
AppendingValidatorContext prepends to submitted errors. That is error
display decoration rather than the field's message; the deprecated JS
validator never applied it either.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5740 fix(core): push the visited chain only when it is complete
Third review pass: with user set and user.address null the found level
was still pushed while the provider got the action, so ${label} in the
Address message rendered the User's label. The chain is now pushed only
when the provider is handed its innermost object, and Form resolves it
once per field instead of once per caller.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5740 fix(core): mirror the validation-time stack and stop sharing
validators across visitors
Fourth review pass. ModelDrivenInterceptor pushes the model above the
action before validation, so a property both declare is validated off
the model; the visited-object lookup now pushes that same pair rather
than the action alone. Visitor validators are cached per visited path
instead of per class, because unwrap() sets a context on the instances
and two visitors over one class overwrote each other's. The wrapper's
list accessors are package-private: only Form reads them, and a public
List<Class<?>> return trips Sonar S1452.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5740 fix(core): take the model validation saw from the stack, not from
getModel()
Fifth review pass. Pushing getModel() whenever the action is
ModelDriven diverged from validation on a stack without the modelDriven
interceptor, re-invoked getModel() per lookup (a fresh instance per
call is not the one validation ran against), and ran it outside the
try. The lookup now reuses the instance directly above the action in
the root, only when ModelDrivenInterceptor is configured for the
action, with every push inside the try.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5740 fix(core): only treat the frame above the action as the model
when the interceptor pushed one
Sixth review pass, and a real one: modelDriven is in defaultStack, so
gating on the configured interceptor alone made every non-ModelDriven
action under <s:iterator> re-push the iterator element as its "model",
reintroducing the shadowing this branch removes. The frame above the
action is taken as the model only when the rendering action is
ModelDriven with a non-null model and ModelDrivenInterceptor is in its
own stack — read from the current invocation's config, not the form
target's. Tests cover the interceptor-configured/non-ModelDriven and
null-model cases the previous negative test could not reach.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5740 fix(core): identity-check the pushed model and use the visited
instance's runtime class
Seventh review pass, two one-liners. The frame above the action counts
as the model only when it is the action's current model, so a model
created after the interceptor ran cannot promote a page frame. And the
visited provider is built from the instance's runtime class when the
instance exists, as VisitorFieldValidator.createTextProvider does, so a
subclass's own bundle wins over the declared type's.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5740 refactor(core): address Sonar findings on the visitor message
context
S1117: two locals named `action` hid the form's own action attribute.
S3358: the provider choice in unwrap() was a nested ternary; it is a
method now, named for what VisitorFieldValidator.createTextProvider does.
S107: findFieldValidators carried the visited classes and paths as two
parallel lists (8 parameters); they are one list of Visit records.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../java/org/apache/struts2/components/Form.java | 254 ++++++++++++++++++++-
.../struts2/components/HtmlConstraintProvider.java | 4 +-
.../java/org/apache/struts2/components/UIBean.java | 18 +-
.../apache/struts2/TestConfigurationProvider.java | 10 +
...onstraintUser.java => AdminConstraintUser.java} | 12 +-
.../struts2/components/ConstraintAction.java | 10 +
...{ConstraintUser.java => ConstraintAddress.java} | 12 +-
.../components/ConstraintAttributesTest.java | 224 +++++++++++++++++-
.../apache/struts2/components/ConstraintUser.java | 27 +++
.../components/FormFieldValidatorsTest.java | 59 ++++-
...tUser.java => ModelDrivenConstraintAction.java} | 15 +-
.../components/AdminConstraintUser.properties | 19 ++
.../components/ConstraintAction-validation.xml | 12 +
...dation.xml => ConstraintAddress-validation.xml} | 4 +-
.../components/ConstraintUser-validation.xml | 12 +-
.../struts2/components/ConstraintUser.properties | 21 ++
16 files changed, 669 insertions(+), 44 deletions(-)
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 d8daf841b..c97755eb5 100644
--- a/core/src/main/java/org/apache/struts2/components/Form.java
+++ b/core/src/main/java/org/apache/struts2/components/Form.java
@@ -18,7 +18,11 @@
*/
package org.apache.struts2.components;
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.ModelDriven;
import org.apache.struts2.ObjectFactory;
+import org.apache.struts2.interceptor.ModelDrivenInterceptor;
+import org.apache.struts2.util.CompoundRoot;
import org.apache.struts2.config.Configuration;
import org.apache.struts2.config.RuntimeConfiguration;
import org.apache.struts2.config.entities.ActionConfig;
@@ -26,7 +30,11 @@ import org.apache.struts2.config.entities.InterceptorMapping;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.MethodFilterInterceptorUtil;
import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.text.CompositeTextProvider;
+import org.apache.struts2.text.TextProvider;
+import org.apache.struts2.text.TextProviderFactory;
import org.apache.struts2.validator.ActionValidatorManager;
+import org.apache.struts2.validator.DelegatingValidatorContext;
import org.apache.struts2.validator.FieldValidator;
import org.apache.struts2.validator.ValidationException;
import org.apache.struts2.validator.ValidationInterceptor;
@@ -44,6 +52,7 @@ import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -129,7 +138,11 @@ public class Form extends ClosingUIBean {
private List<Validator> cachedActionValidators;
private String cachedActionName;
private boolean actionValidatorsResolved;
- private final Map<Class<?>, List<Validator>> cachedVisitorValidators = new
HashMap<>();
+ private final Map<String, List<Validator>> cachedVisitorValidators = new
HashMap<>();
+ private final Map<String, String> visitedPaths = new HashMap<>();
+ private final Map<String, List<Object>> visitedObjects = new HashMap<>();
+ private Boolean modelDrivenConfigured;
+ protected TextProviderFactory textProviderFactory;
public Form(ValueStack stack, HttpServletRequest request,
HttpServletResponse response) {
super(stack, request, response);
@@ -170,6 +183,11 @@ public class Form extends ClosingUIBean {
this.actionValidatorManager = mgr;
}
+ @Inject
+ public void setTextProviderFactory(TextProviderFactory
textProviderFactory) {
+ this.textProviderFactory = textProviderFactory;
+ }
+
/*
* Revised for Portlet actionURL as form action, and add wwAction as hidden
@@ -340,13 +358,179 @@ public class Form extends ClosingUIBean {
Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
List<Validator> validators = new ArrayList<>();
findFieldValidators(name, actionClass, cachedActionName,
cachedActionValidators, validators, "");
+ recordVisitedPath(name, validators);
// the wrapper only exists to prefix the field name for the deprecated
JS validator; callers of
// this method dispatch on the concrete validator type
validators.replaceAll(validator -> validator instanceof
FieldVisitorValidatorWrapper wrapper
- ? wrapper.getFieldValidator() : validator);
+ ? unwrap(wrapper) : validator);
return validators;
}
+ /**
+ * A field has one validated object only when every validator found for it
came through the same
+ * visitor: two {@code appendPrefix="false"} visitors declaring the same
field, or a visitor-nested
+ * validator next to a direct one, leave nothing sensible to hand the
provider.
+ */
+ private void recordVisitedPath(String name, List<Validator> validators) {
+ Set<String> paths = new HashSet<>();
+ for (Validator validator : validators) {
+ paths.add(validator instanceof FieldVisitorValidatorWrapper
wrapper ? wrapper.getVisitedPath() : null);
+ }
+ visitedObjects.remove(name);
+ if (paths.size() == 1 && !paths.contains(null)) {
+ visitedPaths.put(name, paths.iterator().next());
+ } else {
+ visitedPaths.remove(name);
+ }
+ }
+
+ /**
+ * The object a field's validators run against: the visited object for a
field reached through a
+ * {@code visitor} validator, otherwise null — the caller then uses the
action, as validation does.
+ * Resolved by {@link #getFieldValidators(String)}, which must run first.
+ *
+ * @since 7.4.0
+ */
+ public Object getValidatedObject(String name) {
+ String path = visitedPaths.get(name);
+ if (path == null) {
+ return null;
+ }
+ List<Object> chain = getVisitedObjects(name);
+ // a partial chain (user set, user.address still null) has no object
of the right type
+ return chain.size() == path.split("\\.").length ?
chain.get(chain.size() - 1) : null;
+ }
+
+ /**
+ * The visited objects on the way to a field, outermost first — {@code
[user, user.address]} for
+ * {@code user.address.street} — as far as they exist. Validation has
every one of them on the
+ * value stack when it resolves the innermost message, so rendering pushes
them too.
+ */
+ List<Object> getVisitedObjects(String name) {
+ String path = visitedPaths.get(name);
+ if (path == null) {
+ return Collections.emptyList();
+ }
+ return visitedObjects.computeIfAbsent(name, key -> resolveChain(path));
+ }
+
+ private List<Object> resolveChain(String path) {
+ List<Object> chain = new ArrayList<>();
+ StringBuilder prefix = new StringBuilder();
+ for (String segment : path.split("\\.")) {
+ prefix.append(prefix.isEmpty() ? "" : ".").append(segment);
+ Object visited = findAsValidation(prefix.toString());
+ if (visited == null) {
+ break;
+ }
+ chain.add(visited);
+ }
+ return chain;
+ }
+
+ private Object currentAction() {
+ ActionInvocation invocation =
getStack().getActionContext().getActionInvocation();
+ return invocation == null ? null : invocation.getAction();
+ }
+
+ /**
+ * Validation reads the visited object off the stack as the interceptors
left it: the action, with
+ * the model {@code ModelDrivenInterceptor} pushed directly above it. At
render time an
+ * {@code <s:iterator>} or {@code <s:push>} frame above those may expose
the same property, so
+ * that validation-time pair is put back on top for the lookup.
+ */
+ private Object findAsValidation(String path) {
+ Object invoked = currentAction();
+ if (invoked == null) {
+ return getStack().findValue(path);
+ }
+ CompoundRoot root = getStack().getRoot();
+ int depth = root.size();
+ try {
+ Object model = modelPushedAbove(invoked, root);
+ getStack().push(invoked);
+ if (model != null) {
+ getStack().push(model);
+ }
+ return getStack().findValue(path);
+ } finally {
+ while (root.size() > depth) {
+ getStack().pop();
+ }
+ }
+ }
+
+ /**
+ * The model the interceptor pushed: the frame directly above the action,
and only when it is the
+ * action's current model — anything else there is a page frame. The
interceptor is in the default
+ * stack, so its being configured alone proves nothing; it is still
checked because a stack without
+ * it never pushes, however the model looks now.
+ */
+ private Object modelPushedAbove(Object action, CompoundRoot root) {
+ if (!(action instanceof ModelDriven<?> modelDriven) ||
modelDriven.getModel() == null) {
+ return null;
+ }
+ int actionIndex = root.indexOf(action);
+ if (actionIndex <= 0 || !modelDrivenInterceptorConfigured()) {
+ return null;
+ }
+ Object above = root.get(actionIndex - 1);
+ return above == modelDriven.getModel() ? above : null;
+ }
+
+ private boolean modelDrivenInterceptorConfigured() {
+ if (modelDrivenConfigured == null) {
+ modelDrivenConfigured = false;
+ ActionInvocation invocation =
getStack().getActionContext().getActionInvocation();
+ ActionConfig actionConfig = invocation == null ||
invocation.getProxy() == null
+ ? null : invocation.getProxy().getConfig();
+ if (actionConfig != null) {
+ for (InterceptorMapping interceptorMapping :
actionConfig.getInterceptors()) {
+ if (interceptorMapping.getInterceptor() instanceof
ModelDrivenInterceptor) {
+ modelDrivenConfigured = true;
+ }
+ }
+ }
+ }
+ return modelDrivenConfigured;
+ }
+
+ /**
+ * Gives the nested validator the text provider {@code
VisitorFieldValidator.validateObject} gives
+ * it: each visited level's bundle, innermost first, then the action's. An
instance is only needed
+ * when it is itself a {@code TextProvider}; the class-based providers
work for a form rendered
+ * before the visited objects exist.
+ */
+ private FieldValidator unwrap(FieldVisitorValidatorWrapper wrapper) {
+ FieldValidator validator = wrapper.getFieldValidator();
+ Object invoked = currentAction();
+ if (wrapper.getVisitedClasses().isEmpty() || invoked == null ||
textProviderFactory == null) {
+ return validator;
+ }
+ DelegatingValidatorContext parent = new
DelegatingValidatorContext(invoked, textProviderFactory);
+ List<TextProvider> providers = new ArrayList<>();
+ List<Class<?>> classes = wrapper.getVisitedClasses();
+ List<String> paths = wrapper.getVisitedPaths();
+ for (int level = classes.size() - 1; level >= 0; level--) {
+
providers.add(visitedTextProvider(findAsValidation(paths.get(level)),
classes.get(level)));
+ }
+ providers.add(parent);
+ validator.setValidatorContext(new DelegatingValidatorContext(parent,
new CompositeTextProvider(providers), parent));
+ return validator;
+ }
+
+ /**
+ * What {@code VisitorFieldValidator.createTextProvider} builds: the
instance itself when it is a
+ * TextProvider, else a provider for its runtime class — or for the
declared class when the instance
+ * does not exist yet.
+ */
+ private TextProvider visitedTextProvider(Object visited, Class<?>
declared) {
+ if (visited instanceof TextProvider textProvider) {
+ return textProvider;
+ }
+ return textProviderFactory.createInstance(visited != null ?
visited.getClass() : declared);
+ }
+
private void resolveActionValidators() {
if (actionValidatorsResolved) {
return;
@@ -389,6 +573,16 @@ public class Form extends ClosingUIBean {
private void findFieldValidators(String name, Class actionClass, String
actionName,
List<Validator> validatorList,
List<Validator> resultValidators, String prefix) {
+ findFieldValidators(name, actionClass, actionName, validatorList,
resultValidators, prefix, Collections.emptyList());
+ }
+
+ /** One level of visitor nesting: the class it validates and the OGNL path
of that object from the action. */
+ private record Visit(Class<?> clazz, String path) {
+ }
+
+ private void findFieldValidators(String name, Class actionClass, String
actionName,
+ List<Validator> validatorList,
List<Validator> resultValidators, String prefix,
+ List<Visit> visits) {
for (Validator validator : validatorList) {
if (validator instanceof FieldValidator fieldValidator) {
@@ -400,23 +594,34 @@ public class Form extends ClosingUIBean {
continue;
}
- List<Validator> visitorValidators =
cachedVisitorValidators.computeIfAbsent(clazz,
- visited ->
actionValidatorManager.getValidators(visited, actionName));
String vPrefix = prefix + (vfValidator.isAppendPrefix() ?
vfValidator.getFieldName() + "." : "");
- findFieldValidators(name, clazz, actionName,
visitorValidators, resultValidators, vPrefix);
+ String vPath = visits.isEmpty() ?
vfValidator.getFieldName()
+ : visits.get(visits.size() - 1).path() + "." +
vfValidator.getFieldName();
+ // per visitor, not per class: unwrap() sets a context on
these instances, and two
+ // visitors over one class must not overwrite each other's
+ List<Validator> visitorValidators =
cachedVisitorValidators.computeIfAbsent(vPath,
+ path -> actionValidatorManager.getValidators(clazz,
actionName));
+ findFieldValidators(name, clazz, actionName,
visitorValidators, resultValidators, vPrefix,
+ append(visits, new Visit(clazz, vPath)));
} else if ((prefix +
fieldValidator.getFieldName()).equals(name)) {
- if (StringUtils.isNotBlank(prefix)) {
- //fixing field name for js side
- FieldVisitorValidatorWrapper wrap = new
FieldVisitorValidatorWrapper(fieldValidator, prefix);
- resultValidators.add(wrap);
- } else {
+ if (visits.isEmpty()) {
resultValidators.add(fieldValidator);
+ } else {
+ //fixing field name for js side
+ resultValidators.add(new
FieldVisitorValidatorWrapper(fieldValidator, prefix,
+ visits.stream().map(Visit::clazz).toList(),
visits.stream().map(Visit::path).toList()));
}
}
}
}
}
+ private static <T> List<T> append(List<T> list, T element) {
+ List<T> appended = new ArrayList<>(list);
+ appended.add(element);
+ return appended;
+ }
+
/**
* Wrap field validator, add visitor's field prefix to the field name.
* Javascript side is not aware of the visitor validators
@@ -428,10 +633,39 @@ public class Form extends ClosingUIBean {
public static class FieldVisitorValidatorWrapper implements FieldValidator
{
private FieldValidator fieldValidator;
private String namePrefix;
+ private final List<Class<?>> visitedClasses;
+ private final List<String> visitedPaths;
public FieldVisitorValidatorWrapper(FieldValidator fv, String
namePrefix) {
+ this(fv, namePrefix, Collections.emptyList(),
Collections.emptyList());
+ }
+
+ /**
+ * @param visitedClasses the classes visited on the way to the field,
outermost first; their
+ * bundles are where the field's messages resolve
+ * @param visitedPaths the OGNL paths of those objects from the
action, e.g.
+ * {@code [user, user.address]}
+ * @since 7.4.0
+ */
+ public FieldVisitorValidatorWrapper(FieldValidator fv, String
namePrefix,
+ List<Class<?>> visitedClasses,
List<String> visitedPaths) {
this.fieldValidator = fv;
this.namePrefix = namePrefix;
+ this.visitedClasses = List.copyOf(visitedClasses);
+ this.visitedPaths = List.copyOf(visitedPaths);
+ }
+
+ List<Class<?>> getVisitedClasses() {
+ return visitedClasses;
+ }
+
+ List<String> getVisitedPaths() {
+ return visitedPaths;
+ }
+
+ /** The innermost visited path, or null when the wrapper carries none.
*/
+ public String getVisitedPath() {
+ return visitedPaths.isEmpty() ? null :
visitedPaths.get(visitedPaths.size() - 1);
}
public String getValidatorType() {
diff --git
a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
index 926065984..070bf6bec 100644
---
a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
+++
b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
@@ -42,7 +42,9 @@ 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
+ * @param action the object the field's validators run against, used
to resolve i18n validator
+ * messages: the action, or the visited object for a
field reached through a
+ * {@code visitor} validator; may be null
* @return attribute name to value; never null, possibly empty
*/
Map<String, String> constraintsFor(List<Validator> validators,
HtmlControlType control, Object action);
diff --git a/core/src/main/java/org/apache/struts2/components/UIBean.java
b/core/src/main/java/org/apache/struts2/components/UIBean.java
index 089950fbe..fb961c8f3 100644
--- a/core/src/main/java/org/apache/struts2/components/UIBean.java
+++ b/core/src/main/java/org/apache/struts2/components/UIBean.java
@@ -22,6 +22,7 @@ import org.apache.struts2.config.ConfigurationException;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.util.TextParseUtil;
import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.validator.Validator;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@@ -954,8 +955,20 @@ public abstract class UIBean extends Component {
}
int stackDepth = stack.getRoot().size();
try {
+ List<Validator> validators = form.getFieldValidators(fieldName);
+ // validation resolves a nested message with every visited object
on the stack; both
+ // happen only when the whole chain exists, so ${...} agrees with
the object handed over
+ // (restoreStackDepth pops the pushes)
+ Object validated = form.getValidatedObject(fieldName);
+ if (validated == null) {
+ validated = resolveAction();
+ } else {
+ for (Object visited : form.getVisitedObjects(fieldName)) {
+ stack.push(visited);
+ }
+ }
Map<String, String> constraints =
htmlConstraintProvider.constraintsFor(
- form.getFieldValidators(fieldName), getControlType(),
resolveAction());
+ validators, getControlType(), validated);
if (constraints.isEmpty()) {
return;
}
@@ -1007,6 +1020,9 @@ public abstract class UIBean extends Component {
* messages against a model or a list element while {@code
ValidationInterceptor} validated the
* action.
*
+ * A field reached through a {@code visitor} validator is validated
against the visited object
+ * instead, so {@link Form#getValidatedObject(String)} takes precedence
when that object exists.
+ *
* @return the action, or null when rendering outside action scope, in
which case the provider
* simply derives no message attributes
*/
diff --git
a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
index c6fabf742..22f15f1c4 100644
--- a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
+++ b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
@@ -39,6 +39,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.interceptor.ModelDrivenInterceptor;
import org.apache.struts2.views.jsp.ui.DoubleValidationAction;
import java.util.HashMap;
@@ -102,6 +103,14 @@ public class TestConfigurationProvider implements
ConfigurationProvider {
.addInterceptor(new InterceptorMapping("validation",
validationInterceptor))
.build();
+ ActionConfig modelDrivenConstraintActionConfig = new
ActionConfig.Builder("", "modelDrivenConstraintAction",
ConstraintAction.class.getName())
+ .addResultConfig(new ResultConfig.Builder(Action.SUCCESS,
ServletDispatcherResult.class.getName())
+ .addParam("location", "success.jsp")
+ .build())
+ .addInterceptor(new InterceptorMapping("modelDriven", new
ModelDrivenInterceptor()))
+ .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")
@@ -128,6 +137,7 @@ public class TestConfigurationProvider implements
ConfigurationProvider {
.addActionConfig(TEST_ACTION_NAME, testActionConfig)
.addActionConfig("doubleValidationAction",
doubleValidationActionConfig)
.addActionConfig("constraintAction", constraintActionConfig)
+ .addActionConfig("modelDrivenConstraintAction",
modelDrivenConstraintActionConfig)
.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/ConstraintUser.java
b/core/src/test/java/org/apache/struts2/components/AdminConstraintUser.java
similarity index 82%
copy from core/src/test/java/org/apache/struts2/components/ConstraintUser.java
copy to
core/src/test/java/org/apache/struts2/components/AdminConstraintUser.java
index 0acfc461b..0809ba892 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
+++ b/core/src/test/java/org/apache/struts2/components/AdminConstraintUser.java
@@ -18,15 +18,5 @@
*/
package org.apache.struts2.components;
-public class ConstraintUser {
-
- private String name;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
+public class AdminConstraintUser extends ConstraintUser {
}
diff --git
a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
index fc75b472a..1d0bed30a 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
@@ -28,6 +28,8 @@ public class ConstraintAction extends ActionSupport {
private String bio;
private String nickname;
private ConstraintUser user;
+ private ConstraintUser owner = new ConstraintUser();
+ private ConstraintUser contact = new ConstraintUser();
private String code;
private String choice;
private Object attachment;
@@ -94,6 +96,14 @@ public class ConstraintAction extends ActionSupport {
this.attachment = attachment;
}
+ public ConstraintUser getOwner() {
+ return owner;
+ }
+
+ public ConstraintUser getContact() {
+ return contact;
+ }
+
@StrutsParameter(depth = 1)
public ConstraintUser getUser() {
return user;
diff --git
a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
b/core/src/test/java/org/apache/struts2/components/ConstraintAddress.java
similarity index 82%
copy from core/src/test/java/org/apache/struts2/components/ConstraintUser.java
copy to core/src/test/java/org/apache/struts2/components/ConstraintAddress.java
index 0acfc461b..85d964d64 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAddress.java
@@ -18,15 +18,15 @@
*/
package org.apache.struts2.components;
-public class ConstraintUser {
+public class ConstraintAddress {
- private String name;
+ private String street;
- public String getName() {
- return name;
+ public String getStreet() {
+ return street;
}
- public void setName(String name) {
- this.name = name;
+ public void setStreet(String street) {
+ this.street = street;
}
}
diff --git
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
index 6c7a59e73..6b9313bc8 100644
---
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
+++
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
@@ -18,6 +18,7 @@
*/
package org.apache.struts2.components;
+import org.apache.struts2.action.Action;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.TestConfigurationProvider;
import org.apache.struts2.mock.MockActionProxy;
@@ -34,8 +35,17 @@ import java.util.Map;
public class ConstraintAttributesTest extends AbstractUITagTest {
private FormTag form;
+
+ private Action actionOverride;
+
+ @Override
+ public Action getAction() {
+ return actionOverride != null ? actionOverride : new
ConstraintAction();
+ }
+
private String theme = "html5";
private String fieldName = "username";
+ private String actionName = "constraintAction";
private final Map<String, String> dynamicAttributes = new HashMap<>();
private String declaredMaxlength;
@@ -77,6 +87,11 @@ public class ConstraintAttributesTest extends
AbstractUITagTest {
assertEquals("3", constraints.get("minlength"));
}
+ /**
+ * The message key lives in the visited class's own bundle ({@code
ConstraintUser.properties}),
+ * which is where {@code VisitorFieldValidator} resolves it during
validation — through a
+ * composite text provider over the visited object and the action.
Rendering must use the same.
+ */
public void testVisitorValidatedNestedFieldGetsTheConcreteConstraint()
throws Exception {
initDispatcherWith("true");
fieldName = "user.name";
@@ -84,10 +99,28 @@ public class ConstraintAttributesTest extends
AbstractUITagTest {
Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
assertNotNull("expected constraints for a visitor-validated field",
constraints);
assertEquals("required", constraints.get("required"));
- assertEquals("name is required",
constraints.get("data-msg-requiredstring"));
+ assertEquals("Name is required",
constraints.get("data-msg-requiredstring"));
assertFalse(constraints.containsKey("data-msg-field-visitor"));
}
+ /**
+ * Validation pushes the visited instance before resolving the message, so
{@code ${label}} in a
+ * visited message reads the visited bean, not the action. Same at render
time when the instance
+ * exists.
+ */
+ public void
testVisitorValidatedMessageResolvesExpressionsAgainstTheVisitedInstance()
throws Exception {
+ initDispatcherWith("true");
+ fieldName = "user.email";
+ ConstraintUser user = new ConstraintUser();
+ user.setLabel("Account");
+ ((ConstraintAction) action).setUser(user);
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Account: e-mail is required",
constraints.get("data-msg-requiredstring"));
+ }
+
/**
* HTML attribute names are ASCII case-insensitive, and the documented
rule is that the developer's
* own value always wins — including one typed as {@code MAXLENGTH}.
@@ -126,6 +159,191 @@ public class ConstraintAttributesTest extends
AbstractUITagTest {
assertEquals("required", constraints.get("required"));
}
+ /**
+ * Validation chains the providers and the stack through every visited
level, so a message key in
+ * the intermediate class's bundle, and a {@code ${...}} reading the
intermediate object, both
+ * resolve for a doubly-nested field.
+ */
+ public void
testDoublyNestedVisitorMessageResolvesThroughTheIntermediateObject() throws
Exception {
+ initDispatcherWith("true");
+ fieldName = "user.address.street";
+ ConstraintUser user = new ConstraintUser();
+ user.setLabel("Account");
+ user.setAddress(new ConstraintAddress());
+ ((ConstraintAction) action).setUser(user);
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Account: street is required",
constraints.get("data-msg-requiredstring"));
+ }
+
+ /**
+ * Validation resolves the visited object from the action. At render time
an {@code <s:iterator>}
+ * or {@code <s:push>} frame above the action may expose the same
property, and must not win.
+ */
+ public void testVisitedObjectIsResolvedFromTheActionNotTheTopOfTheStack()
throws Exception {
+ initDispatcherWith("true");
+ fieldName = "user.email";
+ ConstraintUser actionsUser = new ConstraintUser();
+ actionsUser.setLabel("Account");
+ ((ConstraintAction) action).setUser(actionsUser);
+ ConstraintAction shadow = new ConstraintAction();
+ ConstraintUser shadowsUser = new ConstraintUser();
+ shadowsUser.setLabel("Shadow");
+ shadow.setUser(shadowsUser);
+ stack.push(shadow);
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Account: e-mail is required",
constraints.get("data-msg-requiredstring"));
+ }
+
+ /**
+ * ModelDrivenInterceptor pushes the model above the action before
validation runs, so a property
+ * both declare is validated off the model. Rendering must read it off the
same instance.
+ */
+ public void
testVisitedObjectIsResolvedFromTheModelWhenTheModelDrivenInterceptorIsConfigured()
throws Exception {
+ actionOverride = new ModelDrivenConstraintAction();
+ actionName = "modelDrivenConstraintAction";
+ initDispatcherWith("true");
+ fieldName = "user.email";
+ ConstraintAction model = userLabelled("Action", "Model");
+ ((ModelDrivenConstraintAction) action).setModel(model);
+ stack.push(model);
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Model: e-mail is required",
constraints.get("data-msg-requiredstring"));
+ }
+
+ /**
+ * modelDriven is in the default stack, so the interceptor being
configured says nothing about
+ * whether it pushed: for an action that is not ModelDriven the frame
above it is a page frame.
+ */
+ public void
testAPageFrameAboveANonModelDrivenActionIsIgnoredEvenWithTheInterceptorConfigured()
throws Exception {
+ actionName = "modelDrivenConstraintAction";
+ initDispatcherWith("true");
+ fieldName = "user.email";
+ stack.push(userLabelled("Action", "Shadow"));
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Action: e-mail is required",
constraints.get("data-msg-requiredstring"));
+ }
+
+ /**
+ * The interceptor pushes nothing for a null model, so the frame above the
action is a page frame.
+ */
+ public void testAPageFrameAboveAModelDrivenActionWithANullModelIsIgnored()
throws Exception {
+ actionOverride = new ModelDrivenConstraintAction();
+ actionName = "modelDrivenConstraintAction";
+ initDispatcherWith("true");
+ fieldName = "user.email";
+ stack.push(userLabelled("Action", "Shadow"));
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Action: e-mail is required",
constraints.get("data-msg-requiredstring"));
+ }
+
+ /**
+ * Without the interceptor configured, whatever sits above the action at
render time is a page
+ * frame, not the model validation saw — so it must not win.
+ */
+ public void
testAnObjectAboveTheActionIsIgnoredWithoutTheModelDrivenInterceptor() throws
Exception {
+ actionOverride = new ModelDrivenConstraintAction();
+ initDispatcherWith("true");
+ fieldName = "user.email";
+ ConstraintAction model = userLabelled("Action", "Model");
+ ((ModelDrivenConstraintAction) action).setModel(model);
+ stack.push(model);
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Action: e-mail is required",
constraints.get("data-msg-requiredstring"));
+ }
+
+ /**
+ * The interceptor pushes the model it saw; a model that only came into
existence later was never
+ * pushed, so the frame above the action is still a page frame.
+ */
+ public void
testAModelCreatedAfterTheInterceptorRanIsNotMistakenForAPushedOne() throws
Exception {
+ actionOverride = new ModelDrivenConstraintAction();
+ actionName = "modelDrivenConstraintAction";
+ initDispatcherWith("true");
+ fieldName = "user.email";
+ ConstraintAction shadow = userLabelled("Action", "Shadow");
+ ((ModelDrivenConstraintAction) action).setModel(new
ConstraintAction());
+ stack.push(shadow);
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Action: e-mail is required",
constraints.get("data-msg-requiredstring"));
+ }
+
+ /**
+ * Validation builds the visited provider from the instance's runtime
class, so a subclass's own
+ * bundle wins over the declared type's.
+ */
+ public void testVisitedProviderUsesTheRuntimeClassOfTheVisitedInstance()
throws Exception {
+ initDispatcherWith("true");
+ fieldName = "user.email";
+ ConstraintUser admin = new AdminConstraintUser();
+ admin.setLabel("Root");
+ ((ConstraintAction) action).setUser(admin);
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+
+ assertNotNull(constraints);
+ assertEquals("Admin Root: e-mail is required",
constraints.get("data-msg-requiredstring"));
+ }
+
+ /** Sets the action's user label and returns another action whose user
carries the other label. */
+ private ConstraintAction userLabelled(String actionsLabel, String
othersLabel) {
+ ConstraintUser actionsUser = new ConstraintUser();
+ actionsUser.setLabel(actionsLabel);
+ ((ConstraintAction) action).setUser(actionsUser);
+ ConstraintAction other = new ConstraintAction();
+ ConstraintUser othersUser = new ConstraintUser();
+ othersUser.setLabel(othersLabel);
+ other.setUser(othersUser);
+ return other;
+ }
+
+ /**
+ * With {@code user} set but {@code user.address} still null there is no
Address to validate
+ * against; the provider gets the action, as for any field whose visited
object does not exist,
+ * rather than the User one level up — and {@code ${...}} agrees with
that, so the User's label
+ * does not leak into an Address message.
+ */
+ public void testAPartialVisitedChainHandsTheProviderTheAction() throws
Exception {
+ initDispatcherWith("true");
+ fieldName = "user.address.street";
+ ConstraintUser user = new ConstraintUser();
+ user.setLabel("Account");
+ ((ConstraintAction) action).setUser(user);
+
+ TextFieldTag field = startField(null);
+ List<Object> captured = new ArrayList<>();
+ ((UIBean) field.getComponent()).setHtmlConstraintProvider((validators,
control, derivedFrom) -> {
+ captured.add(derivedFrom);
+ captured.add(validators.get(0).getMessage(derivedFrom));
+ return Collections.emptyMap();
+ });
+
+ finishField(field);
+
+ assertSame(action, captured.get(0));
+ assertEquals(": street is required", captured.get(1));
+ }
+
/**
* 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
@@ -214,7 +432,7 @@ public class ConstraintAttributesTest extends
AbstractUITagTest {
private TextFieldTag startField(String type) throws Exception {
form = new FormTag();
form.setPageContext(pageContext);
- form.setAction("constraintAction");
+ form.setAction(actionName);
form.setNamespace("");
form.setTheme(theme);
form.doStartTag();
@@ -249,6 +467,6 @@ public class ConstraintAttributesTest extends
AbstractUITagTest {
// 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"));
+ configuration.getRuntimeConfiguration().getActionConfig("",
actionName));
}
}
diff --git
a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
b/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
index 0acfc461b..939aa91b4 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
@@ -21,6 +21,9 @@ package org.apache.struts2.components;
public class ConstraintUser {
private String name;
+ private String email;
+ private String label = "Name";
+ private ConstraintAddress address;
public String getName() {
return name;
@@ -29,4 +32,28 @@ public class ConstraintUser {
public void setName(String name) {
this.name = name;
}
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+
+ public ConstraintAddress getAddress() {
+ return address;
+ }
+
+ public void setAddress(ConstraintAddress address) {
+ this.address = address;
+ }
}
diff --git
a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
index 0b09f608e..75590453b 100644
---
a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
+++
b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
@@ -18,6 +18,7 @@
*/
package org.apache.struts2.components;
+import org.apache.struts2.action.Action;
import org.apache.struts2.TestConfigurationProvider;
import org.apache.struts2.mock.MockActionProxy;
import org.apache.struts2.validator.ActionValidatorManager;
@@ -42,6 +43,11 @@ import static org.mockito.Mockito.when;
public class FormFieldValidatorsTest extends AbstractUITagTest {
+ @Override
+ public Action getAction() {
+ return new ConstraintAction();
+ }
+
public void testFindsTheFieldsValidators() throws Exception {
Form form = formForDoubleValidationAction();
@@ -108,11 +114,58 @@ public class FormFieldValidatorsTest extends
AbstractUITagTest {
validators.get(0) instanceof RequiredStringValidator);
}
+ public void
testUnwrappedVisitorValidatorResolvesMessagesFromTheVisitedClassBundle() throws
Exception {
+ currentActionIs("constraintAction");
+ Form form = formFor("constraintAction");
+
+ Validator validator = form.getFieldValidators("user.name").get(0);
+
+ assertEquals("Name is required", validator.getMessage(action));
+ }
+
+ public void
testValidatedObjectIsTheVisitedInstanceForAVisitorNestedField() throws
Exception {
+ currentActionIs("constraintAction");
+ ConstraintUser user = new ConstraintUser();
+ ((ConstraintAction) action).setUser(user);
+ Form form = formFor("constraintAction");
+
+ form.getFieldValidators("user.name");
+
+ assertSame(user, form.getValidatedObject("user.name"));
+ }
+
+ /**
+ * Two {@code appendPrefix="false"} visitors over classes that both
declare {@code name} give the
+ * field two validators from two objects; there is no single object to
hand the provider, so it
+ * falls back to the action rather than picking one at random.
+ */
+ public void
testValidatedObjectIsNullWhenTwoVisitorsClaimTheSameUnprefixedField() throws
Exception {
+ currentActionIs("constraintAction");
+ Form form = formFor("constraintAction");
+
+ List<Validator> validators = form.getFieldValidators("name");
+
+ assertEquals(2, validators.size());
+ assertNotSame("two visitors must not share one validator instance, or
the second context "
+ + "overwrites the first", validators.get(0), validators.get(1));
+ assertNull(form.getValidatedObject("name"));
+ }
+
+ public void testValidatedObjectIsNullForADirectField() throws Exception {
+ currentActionIs("constraintAction");
+ Form form = formFor("constraintAction");
+
+ form.getFieldValidators("username");
+
+ assertNull(form.getValidatedObject("username"));
+ }
+
/**
* The manager caches only validator configs and builds fresh instances on
every call, so the
- * visitor branch must be resolved once per form like the top-level list,
not once per field.
+ * visitor branch must be resolved once per visitor per form, not once per
field. The fixture
+ * declares three visitors over ConstraintUser (user, owner, contact).
*/
- public void testResolvesAVisitorsValidatorsOnlyOnceAcrossFields() throws
Exception {
+ public void
testResolvesAVisitorsValidatorsOnlyOncePerVisitorAcrossFields() throws
Exception {
currentActionIs("constraintAction");
Form form = formFor("constraintAction");
ActionValidatorManager manager =
spy(container.getInstance(ActionValidatorManager.class));
@@ -122,7 +175,7 @@ public class FormFieldValidatorsTest extends
AbstractUITagTest {
form.getFieldValidators("username");
form.getFieldValidators("bio");
- then(manager).should(times(1)).getValidators(eq(ConstraintUser.class),
anyString());
+ then(manager).should(times(3)).getValidators(eq(ConstraintUser.class),
anyString());
}
private void currentActionIs(String actionName) {
diff --git
a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
b/core/src/test/java/org/apache/struts2/components/ModelDrivenConstraintAction.java
similarity index 71%
copy from core/src/test/java/org/apache/struts2/components/ConstraintUser.java
copy to
core/src/test/java/org/apache/struts2/components/ModelDrivenConstraintAction.java
index 0acfc461b..f26826c53 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
+++
b/core/src/test/java/org/apache/struts2/components/ModelDrivenConstraintAction.java
@@ -18,15 +18,18 @@
*/
package org.apache.struts2.components;
-public class ConstraintUser {
+import org.apache.struts2.ModelDriven;
- private String name;
+public class ModelDrivenConstraintAction extends ConstraintAction implements
ModelDriven<ConstraintAction> {
- public String getName() {
- return name;
+ private ConstraintAction model;
+
+ @Override
+ public ConstraintAction getModel() {
+ return model;
}
- public void setName(String name) {
- this.name = name;
+ public void setModel(ConstraintAction model) {
+ this.model = model;
}
}
diff --git
a/core/src/test/resources/org/apache/struts2/components/AdminConstraintUser.properties
b/core/src/test/resources/org/apache/struts2/components/AdminConstraintUser.properties
new file mode 100644
index 000000000..3554e4d4b
--- /dev/null
+++
b/core/src/test/resources/org/apache/struts2/components/AdminConstraintUser.properties
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+email.required=Admin ${label}: e-mail is required
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
index 6c847a3c3..d91997de3 100644
---
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
+++
b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
@@ -63,4 +63,16 @@
<message/>
</field-validator>
</field>
+ <field name="owner">
+ <field-validator type="visitor">
+ <param name="appendPrefix">false</param>
+ <message/>
+ </field-validator>
+ </field>
+ <field name="contact">
+ <field-validator type="visitor">
+ <param name="appendPrefix">false</param>
+ <message/>
+ </field-validator>
+ </field>
</validators>
diff --git
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
b/core/src/test/resources/org/apache/struts2/components/ConstraintAddress-validation.xml
similarity index 93%
copy from
core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
copy to
core/src/test/resources/org/apache/struts2/components/ConstraintAddress-validation.xml
index b9611d705..0f28f0a7a 100644
---
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
+++
b/core/src/test/resources/org/apache/struts2/components/ConstraintAddress-validation.xml
@@ -21,9 +21,9 @@
-->
<!DOCTYPE validators PUBLIC "-//Apache Struts//XWork Validator 1.0//EN"
"https://struts.apache.org/dtds/xwork-validator-1.0.dtd">
<validators>
- <field name="name">
+ <field name="street">
<field-validator type="requiredstring">
- <message>name is required</message>
+ <message key="street.required"/>
</field-validator>
</field>
</validators>
diff --git
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
index b9611d705..12d0529cb 100644
---
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
+++
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
@@ -23,7 +23,17 @@
<validators>
<field name="name">
<field-validator type="requiredstring">
- <message>name is required</message>
+ <message key="name.required"/>
+ </field-validator>
+ </field>
+ <field name="email">
+ <field-validator type="requiredstring">
+ <message key="email.required"/>
+ </field-validator>
+ </field>
+ <field name="address">
+ <field-validator type="visitor">
+ <message/>
</field-validator>
</field>
</validators>
diff --git
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser.properties
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser.properties
new file mode 100644
index 000000000..a64bbaadf
--- /dev/null
+++
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser.properties
@@ -0,0 +1,21 @@
+#
+# 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.
+#
+name.required=Name is required
+email.required=${label}: e-mail is required
+street.required=${label}: street is required