Skip to content

Commit 78763c1

Browse files
committed
Add support for option validation with the Bean Validation API
1 parent e993c3a commit 78763c1

File tree

6 files changed

+88
-7
lines changed

6 files changed

+88
-7
lines changed

spring-shell-core/src/main/java/org/springframework/shell/core/command/AbstractCommand.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@
2020
import java.util.List;
2121
import java.util.Objects;
2222

23+
import jakarta.validation.Path;
24+
2325
import org.jspecify.annotations.Nullable;
2426

27+
import org.springframework.shell.core.ParameterValidationException;
2528
import org.springframework.shell.core.command.availability.Availability;
2629
import org.springframework.shell.core.command.availability.AvailabilityProvider;
2730
import org.springframework.shell.core.command.exit.ExitStatusExceptionMapper;
@@ -134,6 +137,17 @@ public ExitStatus execute(CommandContext commandContext) throws Exception {
134137
try {
135138
return doExecute(commandContext);
136139
}
140+
catch (ParameterValidationException parameterValidationException) {
141+
PrintWriter outputWriter = commandContext.outputWriter();
142+
outputWriter.println("The following constraints were not met:");
143+
parameterValidationException.getConstraintViolations().forEach(violation -> {
144+
Path propertyPath = violation.getPropertyPath();
145+
String violationMessage = violation.getMessage();
146+
String errorMessage = String.format("\t--%s: %s", extractPropertyName(propertyPath), violationMessage);
147+
outputWriter.println(errorMessage);
148+
});
149+
return ExitStatus.USAGE_ERROR;
150+
}
137151
catch (Exception e) {
138152
if (getExitStatusExceptionMapper() != null) {
139153
return getExitStatusExceptionMapper().apply(e);
@@ -144,6 +158,12 @@ public ExitStatus execute(CommandContext commandContext) throws Exception {
144158
}
145159
}
146160

161+
private String extractPropertyName(Path propertyPath) {
162+
String path = propertyPath.toString();
163+
int lastIndexOfDot = path.lastIndexOf(".");
164+
return lastIndexOfDot == -1 ? path : path.substring(lastIndexOfDot + 1);
165+
}
166+
147167
protected void println(String message, CommandContext commandContext) {
148168
PrintWriter outputWriter = commandContext.outputWriter();
149169
outputWriter.println(message);

spring-shell-core/src/main/java/org/springframework/shell/core/command/adapter/MethodInvokerCommandAdapter.java

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,24 @@
1919
import java.lang.reflect.Parameter;
2020
import java.util.ArrayList;
2121
import java.util.List;
22+
import java.util.Set;
23+
24+
import jakarta.validation.ConstraintViolation;
25+
import jakarta.validation.Validator;
2226

2327
import org.apache.commons.logging.Log;
2428
import org.apache.commons.logging.LogFactory;
2529

2630
import org.springframework.core.convert.support.ConfigurableConversionService;
27-
import org.springframework.shell.core.command.*;
31+
import org.springframework.shell.core.ParameterValidationException;
32+
import org.springframework.shell.core.command.AbstractCommand;
33+
import org.springframework.shell.core.command.CommandArgument;
34+
import org.springframework.shell.core.command.CommandContext;
35+
import org.springframework.shell.core.command.CommandOption;
36+
import org.springframework.shell.core.command.ExitStatus;
2837
import org.springframework.shell.core.command.annotation.Argument;
2938
import org.springframework.shell.core.command.annotation.Arguments;
3039
import org.springframework.shell.core.command.annotation.Option;
31-
import org.springframework.shell.core.command.AbstractCommand;
3240
import org.springframework.util.MethodInvoker;
3341

3442
/**
@@ -45,6 +53,8 @@ public class MethodInvokerCommandAdapter extends AbstractCommand {
4553

4654
private final Object targetObject;
4755

56+
private final Validator validator;
57+
4858
private final ConfigurableConversionService conversionService;
4959

5060
/**
@@ -59,11 +69,12 @@ public class MethodInvokerCommandAdapter extends AbstractCommand {
5969
* @param conversionService the conversion service to use for parameter conversion
6070
*/
6171
public MethodInvokerCommandAdapter(String name, String description, String group, String help, boolean hidden,
62-
Method method, Object targetObject, ConfigurableConversionService conversionService) {
72+
Method method, Object targetObject, ConfigurableConversionService conversionService, Validator validator) {
6373
super(name, description, group, help, hidden);
6474
this.method = method;
6575
this.targetObject = targetObject;
6676
this.conversionService = conversionService;
77+
this.validator = validator;
6778
}
6879

6980
@Override
@@ -79,16 +90,25 @@ public ExitStatus doExecute(CommandContext commandContext) throws Exception {
7990
Class<?>[] parameterTypes = this.method.getParameterTypes();
8091

8192
// TODO should be able to mix CommandContext and other parameters
93+
Object[] argumentsArray = null;
8294
if (parameterTypes.length == 1
8395
&& parameterTypes[0].equals(org.springframework.shell.core.command.CommandContext.class)) {
84-
methodInvoker.setArguments(commandContext);
96+
argumentsArray = new Object[] { commandContext };
8597
}
8698
else {
8799
List<Object> arguments = prepareArguments(commandContext);
88-
methodInvoker.setArguments(arguments.toArray());
100+
argumentsArray = arguments.toArray();
89101
}
102+
methodInvoker.setArguments(argumentsArray);
90103
methodInvoker.prepare();
91104

105+
// validate parameters
106+
Set<ConstraintViolation<Object>> constraintViolations = validator.forExecutables()
107+
.validateParameters(targetObject, method, argumentsArray);
108+
if (!constraintViolations.isEmpty()) {
109+
throw new ParameterValidationException(constraintViolations);
110+
}
111+
92112
// invoke method
93113
methodInvoker.invoke();
94114
commandContext.outputWriter().flush();

spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import java.lang.reflect.Method;
1919
import java.util.Arrays;
2020

21+
import jakarta.validation.Validator;
22+
2123
import org.apache.commons.logging.Log;
2224
import org.apache.commons.logging.LogFactory;
2325

@@ -34,6 +36,7 @@
3436
import org.springframework.shell.core.command.adapter.MethodInvokerCommandAdapter;
3537
import org.springframework.shell.core.command.availability.AvailabilityProvider;
3638
import org.springframework.shell.core.command.exit.ExitStatusExceptionMapper;
39+
import org.springframework.shell.core.utils.Utils;
3740
import org.springframework.util.Assert;
3841

3942
/**
@@ -114,8 +117,15 @@ Ensure that the declaring class is annotated with a Spring stereotype annotation
114117
+ "', using default exception mapping strategy.");
115118
}
116119
}
120+
Validator validator = Utils.defaultValidator();
121+
try {
122+
validator = this.applicationContext.getBean(Validator.class);
123+
}
124+
catch (BeansException e) {
125+
log.debug("No Validator bean found, using default validator.");
126+
}
117127
MethodInvokerCommandAdapter methodInvokerCommandAdapter = new MethodInvokerCommandAdapter(name, description,
118-
group, help, hidden, this.method, targetObject, configurableConversionService);
128+
group, help, hidden, this.method, targetObject, configurableConversionService, validator);
119129
methodInvokerCommandAdapter.setAliases(Arrays.stream(aliases).toList());
120130
methodInvokerCommandAdapter.setAvailabilityProvider(availabilityProviderBean);
121131
if (exitStatusExceptionMapperBean != null) {

spring-shell-docs/modules/ROOT/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
** xref:commands/registration/annotation.adoc[]
1010
** xref:commands/organize.adoc[]
1111
** xref:commands/syntax.adoc[]
12+
** xref:commands/validation.adoc[]
1213
** xref:commands/context.adoc[]
1314
** xref:commands/registry.adoc[]
1415
** xref:commands/availability.adoc[]

spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ include::{snippets}/CommandAnnotationSnippets.java[tag=command-option-arg]
1313
----
1414

1515
Options are defined using `@Option` annotation, while arguments are defined using `@Argument` annotation.
16-
Options are named, while arguments are positional.
16+
Options are named, while arguments are positional. Options can have short names (single character) and long names (multi-character).
17+
18+
Options can be validated using the Bean Validation API by adding validation annotations to the method parameters, see the xref:commands/validation.adoc[Validating Command Options] section for more details.
1719

1820
== Parsing rules
1921

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[[validating-command-options]]
2+
= Option Validation
3+
4+
Spring Shell integrates with the https://beanvalidation.org/[Bean Validation API] to support
5+
automatic and self-documenting constraints on command parameters.
6+
7+
Annotations found on command parameters are honored and trigger validation prior to the command executing.
8+
Consider the following command:
9+
10+
[source, java]
11+
----
12+
@Command(name = "change-password", description = "Change password", group = "User Management",
13+
help = "A command that changes the user password. Usage: change-password [-p | --password]=<password>")
14+
public String changePassword(
15+
@Option(shortName = 'p', longName = "password") @Size(min = 8, max = 40) String password) {
16+
return "Password successfully set to " + password;
17+
}
18+
----
19+
20+
From the preceding example, you get the following behavior for free:
21+
22+
[source, bash]
23+
----
24+
$>change-password --password=hello
25+
The following constraints were not met:
26+
--password: size must be between 8 and 40
27+
Error while executing command change-password: USAGE_ERROR
28+
----

0 commit comments

Comments
 (0)