diff --git a/spring-shell-core/src/main/java/org/springframework/shell/ParameterDescription.java b/spring-shell-core/src/main/java/org/springframework/shell/ParameterDescription.java index a7a49f66..402499cf 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/ParameterDescription.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/ParameterDescription.java @@ -23,6 +23,8 @@ import java.util.Optional; import org.springframework.core.MethodParameter; +import javax.validation.metadata.ElementDescriptor; + /** * Encapsulates information about a shell invokable method parameter, so that it can be documented. * @@ -75,6 +77,14 @@ public class ParameterDescription { */ private String help = ""; + /** + * Allows discovery of bean validation constraints for the command parameter. + *

Note that most often, constraints will directly come from parameter constraints, + * but sometimes (e.g. in case of one method argument mapping to multiple + * command options) may come from property constraints.

+ */ + private ElementDescriptor elementDescriptor; + public ParameterDescription(MethodParameter parameter, String type) { this.parameter = parameter; this.type = type; @@ -123,6 +133,17 @@ public class ParameterDescription { return this; } + /** + * @return an ElementDescriptor used to discover constraints. May be {@literal null}. + */ + public ElementDescriptor elementDescriptor() { + return this.elementDescriptor; + } + + public ParameterDescription elementDescriptor(ElementDescriptor descriptor) { + this.elementDescriptor = descriptor; + return this; + } public String type() { return type; diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java index b15ee3e6..7611b12a 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java @@ -29,6 +29,7 @@ import java.util.stream.Collectors; import javax.annotation.PostConstruct; import javax.validation.ConstraintViolation; import javax.validation.Validation; +import javax.validation.Validator; import javax.validation.executable.ExecutableValidator; import org.springframework.beans.factory.annotation.Autowired; @@ -54,6 +55,9 @@ public class Shell implements CommandRegistry { @Autowired protected ApplicationContext applicationContext; + @Autowired(required = false) + private Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + protected Map methodTargets = new HashMap<>(); protected List parameterResolvers; @@ -63,9 +67,6 @@ public class Shell implements CommandRegistry { */ protected static final Object UNRESOLVED = new Object(); - private final ExecutableValidator executableValidator = Validation - .buildDefaultValidatorFactory().getValidator().forExecutables(); - public Shell(ResultHandler resultHandler) { this.resultHandler = resultHandler; } @@ -232,7 +233,7 @@ public class Shell implements CommandRegistry { throw new IllegalStateException("Could not resolve " + methodParameter); } } - Set> constraintViolations = executableValidator.validateParameters( + Set> constraintViolations = validator.forExecutables().validateParameters( methodTarget.getBean(), methodTarget.getMethod(), args diff --git a/spring-shell-core/src/main/java/org/springframework/shell/SpringShellAutoConfiguration.java b/spring-shell-core/src/main/java/org/springframework/shell/SpringShellAutoConfiguration.java index 9d221d92..f27d800c 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/SpringShellAutoConfiguration.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/SpringShellAutoConfiguration.java @@ -27,6 +27,9 @@ import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.shell.result.ResultHandlerConfig; +import javax.validation.Validation; +import javax.validation.Validator; + /** * Creates supporting beans for running the Shell */ @@ -40,10 +43,15 @@ public class SpringShellAutoConfiguration { return new DefaultConversionService(); } + @Bean + @ConditionalOnMissingBean(Validator.class) + public Validator validator() { + return Validation.buildDefaultValidatorFactory().getValidator(); + } + @Bean public Shell shell(@Qualifier("main") ResultHandler resultHandler) { return new Shell(resultHandler); } - } diff --git a/spring-shell-jcommander-adapter/src/main/java/org/springframework/shell/jcommander/JCommanderParameterResolver.java b/spring-shell-jcommander-adapter/src/main/java/org/springframework/shell/jcommander/JCommanderParameterResolver.java index 0747b43d..916489c7 100644 --- a/spring-shell-jcommander-adapter/src/main/java/org/springframework/shell/jcommander/JCommanderParameterResolver.java +++ b/spring-shell-jcommander-adapter/src/main/java/org/springframework/shell/jcommander/JCommanderParameterResolver.java @@ -28,6 +28,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.MethodParameter; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; @@ -42,6 +43,12 @@ import com.beust.jcommander.Parameter; import com.beust.jcommander.ParameterException; import com.beust.jcommander.ParametersDelegate; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.metadata.BeanDescriptor; +import javax.validation.metadata.MethodDescriptor; +import javax.validation.metadata.ParameterDescriptor; + /** * Provides integration with JCommander. * @@ -52,6 +59,9 @@ public class JCommanderParameterResolver implements ParameterResolver { private static final Collection> JCOMMANDER_ANNOTATIONS = Arrays.asList(Parameter.class, DynamicParameter.class, ParametersDelegate.class); + @Autowired(required = false) + private Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + @Override public boolean supports(MethodParameter parameter) { AtomicBoolean isSupported = new AtomicBoolean(false); @@ -94,6 +104,9 @@ public class JCommanderParameterResolver implements ParameterResolver { public Stream describe(MethodParameter parameter) { JCommander jCommander = createJCommander(parameter); Stream jCommanderDescriptions = streamAllJCommanderDescriptions(jCommander); + + BeanDescriptor constraintsForClass = validator.getConstraintsForClass(parameter.getParameterType()); + return jCommanderDescriptions .map(j -> new ParameterDescription(parameter, unCamelify(j.getParameterized().getType().getSimpleName())) .keys(Arrays.asList(j.getParameter().names())) @@ -101,6 +114,7 @@ public class JCommanderParameterResolver implements ParameterResolver { .mandatoryKey(!j.equals(jCommander.getMainParameter())) // Not ideal as this does not take reverse-conversion into account, but just toString() .defaultValue(j.getDefault() == null ? "" : String.valueOf(j.getDefault())) + .elementDescriptor(constraintsForClass.getConstraintsForProperty(j.getParameterized().getName())) ); } diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/legacy/LegacyCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/legacy/LegacyCommands.java index 6df68438..302d071b 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/legacy/LegacyCommands.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/legacy/LegacyCommands.java @@ -26,6 +26,8 @@ import org.springframework.shell.core.annotation.CliOption; import org.springframework.stereotype.Component; import org.springframework.util.ReflectionUtils; +import javax.validation.constraints.Min; + /** * A sample of legacy Shell 1 commands that can be run thanks to the legacy adapter. * @@ -76,7 +78,7 @@ public class LegacyCommands implements CommandMarker { @CliCommand(value = "sum2", help = "adds two numbers") public int sum2( - @CliOption(key = "v1", unspecifiedDefaultValue = "38") int a, + @CliOption(key = "v1", unspecifiedDefaultValue = "38") @Min(5) int a, @CliOption(key = "v2", specifiedDefaultValue = "42", mandatory = true) int b, @CliOption(key = "v3", mandatory = false) int c) { return a + b + c; diff --git a/spring-shell-shell1-adapter/src/main/java/org/springframework/shell/legacy/LegacyParameterResolver.java b/spring-shell-shell1-adapter/src/main/java/org/springframework/shell/legacy/LegacyParameterResolver.java index 6a4ddbb0..88bf5314 100644 --- a/spring-shell-shell1-adapter/src/main/java/org/springframework/shell/legacy/LegacyParameterResolver.java +++ b/spring-shell-shell1-adapter/src/main/java/org/springframework/shell/legacy/LegacyParameterResolver.java @@ -37,6 +37,11 @@ import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.metadata.MethodDescriptor; +import javax.validation.metadata.ParameterDescriptor; + /** * Resolves parameters by looking at the {@link CliOption} annotation and acting * accordingly. @@ -58,6 +63,9 @@ public class LegacyParameterResolver implements ParameterResolver { @Autowired(required = false) private Collection> converters = new ArrayList<>(); + @Autowired(required = false) + private Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + @Override public boolean supports(MethodParameter parameter) { return parameter.hasParameterAnnotation(CliOption.class); @@ -118,6 +126,15 @@ public class LegacyParameterResolver implements ParameterResolver { } boolean containsEmptyKey = keys.contains(""); result.mandatoryKey(!containsEmptyKey); + + MethodDescriptor constraintsForMethod = validator.getConstraintsForClass(parameter.getDeclaringClass()) + .getConstraintsForMethod(parameter.getMethod().getName(), parameter.getMethod().getParameterTypes()); + if (constraintsForMethod != null) { + ParameterDescriptor constraintsDescriptor = constraintsForMethod + .getParameterDescriptors().get(parameter.getParameterIndex()); + result.elementDescriptor(constraintsDescriptor); + } + return Stream.of(result); } diff --git a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java index 8fc9a743..774da0f1 100644 --- a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java +++ b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java @@ -27,6 +27,7 @@ import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; +import org.hibernate.validator.internal.engine.MessageInterpolatorContext; import org.jline.utils.AttributedStringBuilder; import org.jline.utils.AttributedStyle; @@ -37,6 +38,10 @@ import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; import org.springframework.shell.standard.ShellOption; +import javax.validation.MessageInterpolator; +import javax.validation.Validation; +import javax.validation.metadata.ConstraintDescriptor; + /** * A command to display help about all available commands. * @@ -48,20 +53,29 @@ public class Help { /** * Marker interface for beans providing {@literal help} functionality to the shell. * - *

To override the help command, simply register your own bean implementing that interface - * and the standard implementation will back off.

+ *

+ * To override the help command, simply register your own bean implementing that interface + * and the standard implementation will back off. + *

* - *

To disable the {@literal help} command entirely, set the {@literal spring.shell.command.help.enabled=false} - * property in the environment.

+ *

+ * To disable the {@literal help} command entirely, set the + * {@literal spring.shell.command.help.enabled=false} property in the environment. + *

* * @author Eric Bottard */ - public interface Command {} + public interface Command { + } private final List parameterResolvers; private CommandRegistry commandRegistry; + @Autowired(required = false) + private MessageInterpolator messageInterpolator = Validation.buildDefaultValidatorFactory() + .getMessageInterpolator(); + @Autowired public Help(List parameterResolvers) { this.parameterResolvers = parameterResolvers; @@ -74,10 +88,9 @@ public class Help { @ShellMethod(value = "Display help about available commands.", prefix = "-") public CharSequence help( - @ShellOption(defaultValue = ShellOption.NULL, - valueProvider = CommandValueProvider.class, - value = {"-C", "--command"}, - help = "The command to obtain help for.") String command) throws IOException { + @ShellOption(defaultValue = ShellOption.NULL, valueProvider = CommandValueProvider.class, value = { "-C", + "--command" }, help = "The command to obtain help for.") String command) + throws IOException { if (command == null) { return listCommands(); } @@ -96,25 +109,46 @@ public class Help { throw new IllegalArgumentException("Unknown command '" + command + "'"); } - // NAME AttributedStringBuilder result = new AttributedStringBuilder().append("\n\n"); - result.append("NAME", AttributedStyle.BOLD).append("\n\t"); - result.append(command).append(" - ").append(methodTarget.getHelp()).append("\n\n"); + List parameterDescriptions = getParameterDescriptions(methodTarget); + + // NAME + documentCommandName(result, command, methodTarget.getHelp()); // SYNOPSYS + documentSynopsys(result, command, parameterDescriptions); + + // OPTIONS + documentOptions(result, parameterDescriptions); + + // ALSO KNOWN AS + documentAliases(result, command, methodTarget); + + // AVAILABILITY + documentAvailability(result, methodTarget); + + result.append("\n"); + return result; + } + + private void documentCommandName(AttributedStringBuilder result, String command, String help) { + result.append("NAME", AttributedStyle.BOLD).append("\n\t"); + result.append(command).append(" - ").append(help).append("\n\n"); + } + + private void documentSynopsys(AttributedStringBuilder result, String command, + List parameterDescriptions) { result.append("SYNOPSYS", AttributedStyle.BOLD).append("\n\t"); result.append(command, AttributedStyle.BOLD); result.append(" "); - List parameterDescriptions = getParameterDescriptions(methodTarget); - for (ParameterDescription description : parameterDescriptions) { - if (description.defaultValue().isPresent() && description.formal().length()>0) { + if (description.defaultValue().isPresent() && description.formal().length() > 0) { result.append("["); // Whole parameter is optional, as there is a default value (1) } List keys = description.keys(); - if(!keys.isEmpty()) { + if (!keys.isEmpty()) { if (!description.mandatoryKey()) { result.append("["); // Specifying a key is optional (ie positional params). (2) } @@ -133,19 +167,21 @@ public class Help { if (description.defaultValueWhenFlag().isPresent()) { result.append("]"); // (close 3) } - if (description.defaultValue().isPresent() && description.formal().length()>0) { + if (description.defaultValue().isPresent() && description.formal().length() > 0) { result.append("]"); // (close 1) } result.append(" "); // two spaces between each param for better legibility } result.append("\n\n"); + } - // OPTIONS + private void documentOptions(AttributedStringBuilder result, List parameterDescriptions) { if (!parameterDescriptions.isEmpty()) { result.append("OPTIONS", AttributedStyle.BOLD).append("\n"); } for (ParameterDescription description : parameterDescriptions) { - result.append("\t").append(description.keys().stream().collect(Collectors.joining(" or ")), AttributedStyle.BOLD); + result.append("\t").append(description.keys().stream().collect(Collectors.joining(" or ")), + AttributedStyle.BOLD); if (description.formal().length() > 0) { if (!description.keys().isEmpty()) { result.append(" "); @@ -159,34 +195,42 @@ public class Help { result.append("\n\t"); } result.append("\t"); - result.append(description.help()); + result.append(description.help()).append('\n'); // Optional parameter if (description.defaultValue().isPresent()) { result - .append(" [Optional, default = ", AttributedStyle.BOLD) + .append("\t\t[Optional, default = ", AttributedStyle.BOLD) .append(description.defaultValue().get(), AttributedStyle.BOLD.italic()); description.defaultValueWhenFlag().ifPresent( - s -> result.append(", or ", AttributedStyle.BOLD) - .append(s, AttributedStyle.BOLD.italic()) - .append(" if used as a flag", AttributedStyle.BOLD) - ); + s -> result.append(", or ", AttributedStyle.BOLD) + .append(s, AttributedStyle.BOLD.italic()) + .append(" if used as a flag", AttributedStyle.BOLD)); result.append("]", AttributedStyle.BOLD); } // Mandatory parameter, but with a default when used as a flag else if (description.defaultValueWhenFlag().isPresent()) { result - .append(" [Mandatory, default = ", AttributedStyle.BOLD) - .append(description.defaultValueWhenFlag().get(), AttributedStyle.BOLD.italic()) - .append(" when used as a flag]", AttributedStyle.BOLD) - ; + .append("\t\t[Mandatory, default = ", AttributedStyle.BOLD) + .append(description.defaultValueWhenFlag().get(), AttributedStyle.BOLD.italic()) + .append(" when used as a flag]", AttributedStyle.BOLD); } // true mandatory parameter else { - result.append(" [Mandatory]", AttributedStyle.BOLD); + result.append("\t\t[Mandatory]", AttributedStyle.BOLD); } - result.append("\n\n"); + result.append('\n'); + if (description.elementDescriptor() != null) { + for (ConstraintDescriptor constraintDescriptor : description.elementDescriptor() + .getConstraintDescriptors()) { + String friendlyConstraint = messageInterpolator.interpolate( + constraintDescriptor.getMessageTemplate(), new DummyContext(constraintDescriptor)); + result.append("\t\t[" + friendlyConstraint + "]\n", AttributedStyle.BOLD); + } + } + result.append('\n'); } + } - // ALSO KNOWN AS + private void documentAliases(AttributedStringBuilder result, String command, MethodTarget methodTarget) { Set aliases = commandRegistry.listCommands().entrySet().stream() .filter(e -> e.getValue().equals(methodTarget)) .map(Map.Entry::getKey) @@ -199,7 +243,9 @@ public class Help { result.append('\t').append(alias).append('\n'); } } + } + private void documentAvailability(AttributedStringBuilder result, MethodTarget methodTarget) { Availability availability = methodTarget.getAvailability(); if (!availability.isAvailable()) { result.append("CURRENTLY UNAVAILABLE", AttributedStyle.BOLD).append("\n"); @@ -207,9 +253,6 @@ public class Help { .append(availability.getReason()) .append(".\n"); } - - result.append("\n"); - return result; } private String first(List keys) { @@ -219,7 +262,8 @@ public class Help { private CharSequence listCommands() { Map> groupedByMethodTarget = commandRegistry.listCommands().entrySet().stream() .collect(Collectors.groupingBy(e -> e.getValue().getHelp(), // Use help() as the grouping key - mapping(Map.Entry::getKey, toCollection(TreeSet::new)))); // accumulate the command 'names' into a sorted set + mapping(Map.Entry::getKey, toCollection(TreeSet::new)))); // accumulate the command 'names' into + // a sorted set // Then display commands, sorted alphabetically by their first alias AttributedStringBuilder result = new AttributedStringBuilder(); @@ -228,16 +272,16 @@ public class Help { groupedByMethodTarget.entrySet().stream() .sorted(sortByFirstElement()) .forEach(e -> result.append(isAvailable(e) ? " " : " * ") - .append(e.getValue().stream().collect(Collectors.joining(", ")), AttributedStyle.BOLD) - .append(": ") - .append(e.getKey()) - .append('\n') - ); + .append(e.getValue().stream().collect(Collectors.joining(", ")), AttributedStyle.BOLD) + .append(": ") + .append(e.getKey()) + .append('\n')); groupedByMethodTarget.entrySet().stream() .filter(e -> !isAvailable(e)) .findAny() - .ifPresent(e -> result.append("\nCommands marked with (*) are currently unavailable.\nType `help ` to learn more.\n")); + .ifPresent(e -> result.append( + "\nCommands marked with (*) are currently unavailable.\nType `help ` to learn more.\n")); return result.append("\n"); } @@ -264,9 +308,34 @@ public class Help { private List getParameterDescriptions(MethodTarget methodTarget) { return Utils.createMethodParameters(methodTarget.getMethod()) - .flatMap(mp -> parameterResolvers.stream().filter(pr -> pr.supports(mp)).limit(1L).flatMap(pr -> pr.describe(mp))) - .collect(Collectors.toList()); + .flatMap(mp -> parameterResolvers.stream().filter(pr -> pr.supports(mp)).limit(1L) + .flatMap(pr -> pr.describe(mp))) + .collect(Collectors.toList()); } + private static class DummyContext implements MessageInterpolator.Context { + + private final ConstraintDescriptor descriptor; + + private DummyContext(ConstraintDescriptor descriptor) { + this.descriptor = descriptor; + } + + @Override + public ConstraintDescriptor getConstraintDescriptor() { + return descriptor; + } + + @Override + public Object getValidatedValue() { + return null; + } + + @Override + public T unwrap(Class type) { + return null; + } + } + } diff --git a/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java b/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java index 81df4ea0..ac8901af 100644 --- a/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java +++ b/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java @@ -47,6 +47,8 @@ import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.util.FileCopyUtils; import org.springframework.util.ReflectionUtils; +import javax.validation.constraints.Max; + /** * Tests for the {@link Help} command. * @@ -133,7 +135,8 @@ public class HelpTest { // Multiple keys and arity 0. Help displayed on next line @ShellOption(help = "Do not ask for confirmation. YOLO", arity = 0, value = {"-f", "--force"}) boolean force, // Single key, arity >= 1. Help displayed on next line. Optional - @ShellOption(help = "The answer to everything", defaultValue = "42", value = "-n") int n, + // Also, bears bean validation annotation + @ShellOption(help = "The answer to everything", defaultValue = "42", value = "-n") @Max(5) int n, // Single key, arity > 1. @ShellOption(help = "Some other parameters", arity = 3, value = "-o") float[] o ) { diff --git a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandHelp.txt b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandHelp.txt index bf3e1392..1df90167 100644 --- a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandHelp.txt +++ b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandHelp.txt @@ -1,23 +1,28 @@ -& -& -NAME& - first-command - A rather extensive description of some command.& -& -SYNOPSYS& - first-command [-r] [-f] [[-n] int] [-o] float float float & -& -OPTIONS& - -r Whether to delete recursively [Optional, default = false]& -& - -f or --force& - Do not ask for confirmation. YOLO [Optional, default = false]& -& - -n int& - The answer to everything [Optional, default = 42]& -& - -o float float float& - Some other parameters [Mandatory]& -& -ALSO KNOWN AS& - 1st-command& -& + + +NAME + first-command - A rather extensive description of some command. + +SYNOPSYS + first-command [-r] [-f] [[-n] int] [-o] float float float + +OPTIONS + -r Whether to delete recursively + [Optional, default = false] + + -f or --force + Do not ask for confirmation. YOLO + [Optional, default = false] + + -n int + The answer to everything + [Optional, default = 42] + [must be less than or equal to 5] + + -o float float float + Some other parameters + [Mandatory] + +ALSO KNOWN AS + 1st-command + diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardParameterResolver.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardParameterResolver.java index 17ad5dba..1535c916 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardParameterResolver.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardParameterResolver.java @@ -54,26 +54,39 @@ import org.springframework.util.Assert; import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ObjectUtils; +import javax.validation.MessageInterpolator; +import javax.validation.Valid; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.metadata.MethodDescriptor; +import javax.validation.metadata.ParameterDescriptor; + /** - * Default ParameterResolver implementation that supports the following features:
    - *
  • named parameters (recognized because they start with some {@link ShellMethod#prefix()})
  • + * Default ParameterResolver implementation that supports the following features: + *
      + *
    • named parameters (recognized because they start with some + * {@link ShellMethod#prefix()})
    • *
    • implicit named parameters (from the actual method parameter name)
    • - *
    • positional parameters (in order, for all parameter values that were not resolved via named - * parameters)
    • + *
    • positional parameters (in order, for all parameter values that were not resolved + * via named parameters)
    • *
    • default values (for all remaining parameters)
    • *
    * - *

    Method arguments can consume several words of input at once (driven by {@link ShellOption#arity()}, default 1). - * If several words are consumed, they will be joined together as a comma separated value and passed to the {@link - * ConversionService} - * (which will typically return a List or array).

    + *

    + * Method arguments can consume several words of input at once (driven by + * {@link ShellOption#arity()}, default 1). If several words are consumed, they will be + * joined together as a comma separated value and passed to the {@link ConversionService} + * (which will typically return a List or array). + *

    * - *

    Boolean parameters are by default expected to have an arity of 0, allowing invocations in the form {@code rm - * --force --dir /foo}: - * the presence of {@code --force} passes {@code true} as a parameter value, while its absence passes {@code false}. - * Both - * the default arity of 0 and the default value of {@code false} can be overridden via {@link ShellOption} - * if needed.

    + *

    + * Boolean parameters are by default expected to have an arity of 0, allowing invocations + * in the form {@code rm + * --force --dir /foo}: the presence of {@code --force} passes {@code true} as a parameter + * value, while its absence passes {@code false}. Both the default arity of 0 and the + * default value of {@code false} can be overridden via {@link ShellOption} if + * needed. + *

    * @author Eric Bottard * @author Florent Biville * @author Camilo Gonzalez @@ -86,9 +99,9 @@ public class StandardParameterResolver implements ParameterResolver { private Collection valueProviders = new HashSet<>(); /** - * A cache from method+input to String representation of actual parameter values. - * Note that the converted result is not cached, to allow dynamic computation to happen at every invocation - * if needed (e.g. if a remote service is involved). + * A cache from method+input to String representation of actual parameter values. Note + * that the converted result is not cached, to allow dynamic computation to happen at + * every invocation if needed (e.g. if a remote service is involved). */ private final Map> parameterCache = new ConcurrentReferenceHashMap<>(); @@ -102,9 +115,13 @@ public class StandardParameterResolver implements ParameterResolver { this.valueProviders = valueProviders; } + @Autowired(required = false) + private Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + @Override public boolean supports(MethodParameter parameter) { - boolean optOut = parameter.hasParameterAnnotation(ShellOption.class) && parameter.getParameterAnnotation(ShellOption.class).optOut(); + boolean optOut = parameter.hasParameterAnnotation(ShellOption.class) + && parameter.getParameterAnnotation(ShellOption.class).optOut(); return !optOut && parameter.getMethodAnnotation(ShellMethod.class) != null; } @@ -120,7 +137,7 @@ public class StandardParameterResolver implements ParameterResolver { Map result = new HashMap<>(); Map namedParameters = new HashMap<>(); - + // index of words that haven't yet been used to resolve parameter values List unusedWords = new ArrayList<>(); @@ -137,17 +154,22 @@ public class StandardParameterResolver implements ParameterResolver { if (i + 1 + arity > words.size()) { String input = words.subList(i, words.size()).stream().collect(Collectors.joining(" ")); - throw new UnfinishedParameterResolutionException(describe(Utils.createMethodParameter(parameter)).findFirst().get(), input); + throw new UnfinishedParameterResolutionException( + describe(Utils.createMethodParameter(parameter)).findFirst().get(), input); } - Assert.isTrue(i + 1 + arity <= words.size(), String.format("Not enough input for parameter '%s'", word)); + Assert.isTrue(i + 1 + arity <= words.size(), + String.format("Not enough input for parameter '%s'", word)); String raw = words.subList(i + 1, i + 1 + arity).stream().collect(Collectors.joining(",")); - Assert.isTrue(!namedParameters.containsKey(key), String.format("Parameter for '%s' has already been specified", word)); + Assert.isTrue(!namedParameters.containsKey(key), + String.format("Parameter for '%s' has already been specified", word)); namedParameters.put(key, raw); if (arity == 0) { boolean defaultValue = booleanDefaultValue(parameter); // Boolean parameter has been specified. Use the opposite of the default value - result.put(parameter, ParameterRawValue.explicit(String.valueOf(!defaultValue), key, from, from)); - } else { + result.put(parameter, + ParameterRawValue.explicit(String.valueOf(!defaultValue), key, from, from)); + } + else { i += arity; result.put(parameter, ParameterRawValue.explicit(raw, key, from, i)); } @@ -162,8 +184,10 @@ public class StandardParameterResolver implements ParameterResolver { Parameter[] parameters = methodParameter.getMethod().getParameters(); for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) { Parameter parameter = parameters[i]; - // Compute the intersection between possible keys for the param and what we've already seen for named params - Collection keys = getKeysForParameter(methodParameter.getMethod(), i).collect(Collectors.toSet()); + // Compute the intersection between possible keys for the param and what we've already + // seen for named params + Collection keys = getKeysForParameter(methodParameter.getMethod(), i) + .collect(Collectors.toSet()); Collection copy = new HashSet<>(keys); copy.retainAll(namedParameters.keySet()); if (copy.isEmpty()) { // Was not set via a key (including aliases), must be positional @@ -179,11 +203,13 @@ public class StandardParameterResolver implements ParameterResolver { } // No more input. Try defaultValues else { Optional defaultValue = defaultValueFor(parameter); - defaultValue.ifPresent(value -> result.put(parameter, ParameterRawValue.implicit(value, null, null, null))); + defaultValue.ifPresent( + value -> result.put(parameter, ParameterRawValue.implicit(value, null, null, null))); } } else if (copy.size() > 1) { - throw new IllegalArgumentException("Named parameter has been specified multiple times via " + quote(copy)); + throw new IllegalArgumentException( + "Named parameter has been specified multiple times via " + quote(copy)); } } @@ -213,7 +239,7 @@ public class StandardParameterResolver implements ParameterResolver { } return null; } - + private BitSet getWordsUsedForValue(ParameterRawValue parameterRawValue) { if (parameterRawValue.from != null) { BitSet wordsUsedForValue = new BitSet(); @@ -232,7 +258,8 @@ public class StandardParameterResolver implements ParameterResolver { return null; } else { - return conversionService.convert(s, TypeDescriptor.valueOf(String.class), new TypeDescriptor(methodParameter)); + return conversionService.convert(s, TypeDescriptor.valueOf(String.class), + new TypeDescriptor(methodParameter)); } } @@ -276,7 +303,8 @@ public class StandardParameterResolver implements ParameterResolver { if (i > 0) { sb.append(" "); } - sb.append(arity > 1 ? unCamelify(removeMultiplicityFromType(parameter).getSimpleName()) : unCamelify(type.getSimpleName())); + sb.append(arity > 1 ? unCamelify(removeMultiplicityFromType(parameter).getSimpleName()) + : unCamelify(type.getSimpleName())); } ParameterDescription result = ParameterDescription.outOf(parameter); result.formal(sb.toString()); @@ -292,6 +320,14 @@ public class StandardParameterResolver implements ParameterResolver { .collect(Collectors.toList())) .mandatoryKey(false); + MethodDescriptor constraintsForMethod = validator.getConstraintsForClass(parameter.getDeclaringClass()) + .getConstraintsForMethod(parameter.getMethod().getName(), parameter.getMethod().getParameterTypes()); + if (constraintsForMethod != null) { + ParameterDescriptor constraintsDescriptor = constraintsForMethod + .getParameterDescriptors().get(parameter.getParameterIndex()); + result.elementDescriptor(constraintsDescriptor); + } + return Stream.of(result); } @@ -309,16 +345,20 @@ public class StandardParameterResolver implements ParameterResolver { arity = getArity(parameter); parameterRawValue = parameterCache.get(cacheKey).get(parameter); set = parameterRawValue.explicit; - } catch (ParameterMissingResolutionException e) { + } + catch (ParameterMissingResolutionException e) { set = false; - } catch (UnfinishedParameterResolutionException e) { + } + catch (UnfinishedParameterResolutionException e) { if (e.getParameterDescription().parameter().equals(methodParameter)) { unfinished = e; set = false; - } else { + } + else { return Collections.emptyList(); } - } catch (Exception e) { + } + catch (Exception e) { unfinished = e; set = false; // Most likely what is already typed would fail resolution (eg type conversion failure) @@ -330,8 +370,10 @@ public class StandardParameterResolver implements ParameterResolver { // 1) parameter not set at all // 2) parameter set via its key, not enough input to consume a value // 3) parameter set with multiple values, enough to cover arity. We're done - // 4) parameter set, and some value bound. But maybe that value is just a prefix to what the user actually wants - // 4.1) or maybe that value was resolved by position, but is a prefix of an actual valid key + // 4) parameter set, and some value bound. But maybe that value is just a prefix to what + // the user actually wants + // 4.1) or maybe that value was resolved by position, but is a prefix of an actual valid + // key if (!set) { if (unfinished == null) { // case 1 above @@ -356,21 +398,24 @@ public class StandardParameterResolver implements ParameterResolver { } if (parameterRawValue.positional()) { - // Case 4.1: There exists "--command foo" and user has typed "--comm" which (wrongly) got resolved as a positional param + // Case 4.1: There exists "--command foo" and user has typed "--comm" which (wrongly) got + // resolved as a positional param result.addAll(argumentKeysThatStartWithContextPrefix(methodParameter, context)); } return result; } } - private List valueCompletions(MethodParameter methodParameter, CompletionContext completionContext) { + private List valueCompletions(MethodParameter methodParameter, + CompletionContext completionContext) { return valueProviders.stream() .filter(vp -> vp.supports(methodParameter, completionContext)) .map(vp -> vp.complete(methodParameter, completionContext, null)) .findFirst().orElseGet(() -> Collections.emptyList()); } - private List argumentKeysThatStartWithContextPrefix(MethodParameter methodParameter, CompletionContext context) { + private List argumentKeysThatStartWithContextPrefix(MethodParameter methodParameter, + CompletionContext context) { String prefix = context.currentWordUpToCursor() != null ? context.currentWordUpToCursor() : ""; return describe(methodParameter).flatMap(pd -> pd.keys().stream()) .filter(k -> k.startsWith(prefix)) @@ -402,8 +447,8 @@ public class StandardParameterResolver implements ParameterResolver { } /** - * Return the arity of a given parameter. The default arity is 1, except for - * booleans where arity is 0 (can be overridden back to 1 via an annotation) + * Return the arity of a given parameter. The default arity is 1, except for booleans + * where arity is 0 (can be overridden back to 1 via an annotation) */ private int getArity(Parameter parameter) { ShellOption option = parameter.getAnnotation(ShellOption.class); @@ -412,8 +457,8 @@ public class StandardParameterResolver implements ParameterResolver { } /** - * Return the key(s) for the i-th parameter of the command method, resolved either from the {@link ShellOption} - * annotation, or from the actual parameter name. + * Return the key(s) for the i-th parameter of the command method, resolved either from + * the {@link ShellOption} annotation, or from the actual parameter name. */ private Stream getKeysForParameter(Method method, int index) { Parameter p = method.getParameters()[index]; @@ -459,8 +504,10 @@ public class StandardParameterResolver implements ParameterResolver { @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; CacheKey cacheKey = (CacheKey) o; return Objects.equals(method, cacheKey.method) && Objects.equals(words, cacheKey.words);