From 1eea04ad2f4395794b012df9a5bab0e1de5ab664 Mon Sep 17 00:00:00 2001 From: Eric Bottard Date: Tue, 22 Aug 2017 18:22:51 +0200 Subject: [PATCH] Add dynamic command availability Introduce availability concept on MethodTarget (with reason if not available) Add bridge to @CliAvailabilityIndicator to Legacy registrar Fixes #138 Add help for unavailable commands Add standard API for availability --- dump.rdb | Bin 0 -> 1149 bytes .../springframework/shell/Availability.java | 51 ++++ .../shell/CommandNotCurrentlyAvailable.java | 42 +++ .../springframework/shell/MethodTarget.java | 25 +- .../java/org/springframework/shell/Shell.java | 23 +- .../src/main/asciidoc/using-spring-shell.adoc | 3 +- .../shell/samples/legacy/LegacyCommands.java | 11 +- .../samples/standard/DynamicCommands.java | 64 +++++ .../legacy/LegacyMethodTargetRegistrar.java | 33 ++- .../LegacyMethodTargetRegistrarTest.java | 2 +- .../shell/standard/commands/Help.java | 27 +- .../shell/standard/commands/HelpTest.java | 9 +- .../commands/HelpTest-testCommandList.txt | 6 +- .../standard/ShellMethodAvailability.java | 48 ++++ .../StandardMethodTargetRegistrar.java | 114 +++++++- .../StandardMethodTargetRegistrarTest.java | 245 ++++++++++++++++++ 16 files changed, 658 insertions(+), 45 deletions(-) create mode 100644 dump.rdb create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/Availability.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/CommandNotCurrentlyAvailable.java create mode 100644 spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/DynamicCommands.java create mode 100644 spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellMethodAvailability.java create mode 100644 spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..e0b2599b0e733996eb4797f839d856c761fd08e0 GIT binary patch literal 1149 zcmb7C%Wl&^6uq%0uArEzi3lns5D93Tw4RO~=K*w)1uWPCLPbKtRvE0QsvCKnMRzQy z)X%6`vEu{S7@05N3(PMdcAyDYPAGLMqeSwNXYSnhIZq!ydh(1h)|Q}@T+a)*=Z4(a zI~@8U4@9AI!DfiQzYp=PdilbWpl-5IBB{rhYVk^WpC1V-20`EV=m8Id{+@_?UhkM3 z1HikQdRqf%WZ&teG!>y64wnVBnMsHKfjb!RH(NiHwL~*?&mDT423!mdeZdo`lMQ)8 zT}`yc>F%Ypd^P$ABR#%D^ST_nQHz=8+0)b9V|nXbJipE?n^wxE5^vf! z#{ztfKbM|K_pi{C!ZH}A6YXmAP&bD!i)Wl6zPp__>*evRN8Am>Z1SF6$*?-b%w#)h f$$xY-t!77`vBmRjMBf)uHb$SnetZ7%L-gto9S&t6 literal 0 HcmV?d00001 diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Availability.java b/spring-shell-core/src/main/java/org/springframework/shell/Availability.java new file mode 100644 index 00000000..dcd2722d --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/Availability.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell; + +import org.springframework.util.Assert; + +/** + * Indicates whether or not a command is currently available. When not available, provides + * a reason. + * + * @author Eric Bottard + */ +public class Availability { + + private final String reason; + + private Availability(String reason) { + this.reason = reason; + } + + public static Availability available() { + return new Availability(null); + } + + public static Availability unavailable(String reason) { + Assert.notNull(reason, "Reason for not being available must be provided"); + return new Availability(reason); + } + + public boolean isAvailable() { + return reason == null; + } + + public String getReason() { + return reason; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/CommandNotCurrentlyAvailable.java b/spring-shell-core/src/main/java/org/springframework/shell/CommandNotCurrentlyAvailable.java new file mode 100644 index 00000000..7293df5b --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/CommandNotCurrentlyAvailable.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell; + +/** + * Indicates that a command exists but is currently not invokable. + * + * @author Eric Bottard + */ +public class CommandNotCurrentlyAvailable extends RuntimeException { + + private final String command; + private final Availability availability; + + public CommandNotCurrentlyAvailable(String command, Availability availability) { + super(String.format("Command '%s' exists but is not currently available because %s", command, availability.getReason())); + this.command = command; + this.availability = availability; + } + + public String getCommand() { + return command; + } + + public Availability getAvailability() { + return availability; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java b/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java index 423a5058..8685968a 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java @@ -19,6 +19,7 @@ package org.springframework.shell; import java.lang.reflect.Method; import java.util.HashSet; import java.util.Set; +import java.util.function.Supplier; import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; @@ -36,7 +37,16 @@ public class MethodTarget { private final String help; + /** + * If not null, returns whether or not the command is currently available. Implementations must be idempotent. + */ + private final Supplier availabilityIndicator; + public MethodTarget(Method method, Object bean, String help) { + this(method, bean, help, null); + } + + public MethodTarget(Method method, Object bean, String help, Supplier availabilityIndicator) { Assert.notNull(method, "Method cannot be null"); Assert.notNull(bean, "Bean cannot be null"); Assert.hasText(help, String.format("Help cannot be blank when trying to define command based on '%s'", method)); @@ -44,6 +54,7 @@ public class MethodTarget { this.method = method; this.bean = bean; this.help = help; + this.availabilityIndicator = availabilityIndicator != null ? availabilityIndicator : () -> Availability.available(); } /** @@ -51,13 +62,21 @@ public class MethodTarget { * in case of overloaded method. */ public static MethodTarget of(String name, Object bean, String help) { + return of(name, bean, help, null); + } + + /** + * Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception + * in case of overloaded method. + */ + public static MethodTarget of(String name, Object bean, String help, Supplier availabilityIndicator) { Set found = new HashSet<>(); ReflectionUtils.doWithMethods(bean.getClass(), found::add, m -> m.getName().equals(name)); if (found.size() != 1) { throw new IllegalArgumentException(String.format("Could not find unique method named '%s' on object of class %s. Found %s", name, bean.getClass(), found)); } - return new MethodTarget(found.iterator().next(), bean, help); + return new MethodTarget(found.iterator().next(), bean, help, availabilityIndicator); } public Method getMethod() { @@ -72,6 +91,10 @@ public class MethodTarget { return help; } + public Availability getAvailability() { + return availabilityIndicator.get(); + } + @Override public boolean equals(Object o) { if (this == o) return true; 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 74230503..2d83003a 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 @@ -131,16 +131,21 @@ public class Shell implements CommandRegistry { Object result; if (command != null) { MethodTarget methodTarget = methodTargets.get(command); - List wordsForArgs = wordsForArguments(command, words); - Method method = methodTarget.getMethod(); + Availability availability = methodTarget.getAvailability(); + if (availability.isAvailable()) { + List wordsForArgs = wordsForArguments(command, words); + Method method = methodTarget.getMethod(); - try { - Object[] args = resolveArgs(method, wordsForArgs); - validateArgs(args, methodTarget); - result = ReflectionUtils.invokeMethod(method, methodTarget.getBean(), args); - } - catch (Exception e) { - result = e; + try { + Object[] args = resolveArgs(method, wordsForArgs); + validateArgs(args, methodTarget); + result = ReflectionUtils.invokeMethod(method, methodTarget.getBean(), args); + } + catch (Exception e) { + result = e; + } + } else { + result = new CommandNotCurrentlyAvailable(command, availability); } } else { diff --git a/spring-shell-docs/src/main/asciidoc/using-spring-shell.adoc b/spring-shell-docs/src/main/asciidoc/using-spring-shell.adoc index d27edd97..4b07d31c 100644 --- a/spring-shell-docs/src/main/asciidoc/using-spring-shell.adoc +++ b/spring-shell-docs/src/main/asciidoc/using-spring-shell.adoc @@ -20,8 +20,9 @@ ==== By Name _vs._ Positional Parameters ==== Quotes Handling ==== Interacting with the Shell -Line Continuation, TAB Completion, Search, etc. +Line Continuation, kbd:[TAB] Completion, Search, etc. +=== Dynamic Command Availability === Built-In Commands * clear 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 bd4964af..cf2925bf 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 @@ -19,6 +19,7 @@ package org.springframework.shell.samples.legacy; import java.lang.reflect.Method; import org.springframework.shell.core.CommandMarker; +import org.springframework.shell.core.annotation.CliAvailabilityIndicator; import org.springframework.shell.core.annotation.CliCommand; import org.springframework.shell.core.annotation.CliOption; import org.springframework.stereotype.Component; @@ -35,6 +36,13 @@ public class LegacyCommands implements CommandMarker { public static final Method REGISTER_METHOD = ReflectionUtils.findMethod(LegacyCommands.class, "register", String.class, ArtifactType.class, String.class, boolean.class); public static final Method SUM_METHOD = ReflectionUtils.findMethod(LegacyCommands.class, "sum", int.class, int.class); + private boolean available = true; + + @CliAvailabilityIndicator("register module") + public boolean registerAvailable() { + return available; + } + @CliCommand(value = "register module", help = "Register a new module") public String register( @CliOption(mandatory = true, @@ -57,10 +65,11 @@ public class LegacyCommands implements CommandMarker { return String.format(("Successfully registered module '%s:%s'"), type, name); } - @CliCommand(value = "sum", help = "adds two numbers") + @CliCommand(value = "sum", help = "adds two numbers. Will also toggle the 'register module' command availability") public int sum( @CliOption(key = "v1", unspecifiedDefaultValue = "38") int a, @CliOption(key = "v2", specifiedDefaultValue = "42") int b) { + available = !available; return a + b; } diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/DynamicCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/DynamicCommands.java new file mode 100644 index 00000000..b2817523 --- /dev/null +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/DynamicCommands.java @@ -0,0 +1,64 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.samples.standard; + +import org.springframework.shell.Availability; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.standard.ShellMethodAvailability; + +/** + * Showcases dynamic command availability. + * + * @author Eric Bottard + */ +@ShellComponent +public class DynamicCommands { + + private boolean connected; + + private boolean authenticated; + + public Availability authenticateAvailability() { + return connected ? Availability.available() : Availability.unavailable("you are not connected"); + } + + @ShellMethod("Authenticate with the system") + public void authenticate(String credentials) { + authenticated = "sesame".equals(credentials); + } + + @ShellMethod("Connect to the system") + public void connect() { + connected = true; + } + + @ShellMethod("Disconnect from the system") + public void disconnect() { + connected = false; + } + + @ShellMethod("Blow Everything up") + @ShellMethodAvailability("dangerousAvailability") + public String blowUp() { + return "Boom!"; + } + + private Availability dangerousAvailability() { + return connected && authenticated ? Availability.available() : Availability.unavailable("you failed to authenticate. Try 'sesame'."); + } +} diff --git a/spring-shell-shell1-adapter/src/main/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrar.java b/spring-shell-shell1-adapter/src/main/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrar.java index 9e53a0d7..fa153b63 100644 --- a/spring-shell-shell1-adapter/src/main/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrar.java +++ b/spring-shell-shell1-adapter/src/main/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrar.java @@ -18,14 +18,16 @@ package org.springframework.shell.legacy; import static org.springframework.util.StringUtils.collectionToDelimitedString; -import java.util.HashMap; -import java.util.Map; -import java.util.TreeMap; +import java.lang.reflect.Method; +import java.util.*; +import java.util.function.Supplier; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; +import org.springframework.shell.Availability; import org.springframework.shell.ConfigurableCommandRegistry; import org.springframework.shell.core.CommandMarker; +import org.springframework.shell.core.annotation.CliAvailabilityIndicator; import org.springframework.shell.core.annotation.CliCommand; import org.springframework.shell.MethodTarget; import org.springframework.shell.MethodTargetRegistrar; @@ -56,7 +58,8 @@ public class LegacyMethodTargetRegistrar implements MethodTargetRegistrar { ReflectionUtils.doWithMethods(clazz, method -> { CliCommand cliCommand = method.getAnnotation(CliCommand.class); for (String key : cliCommand.value()) { - MethodTarget target = new MethodTarget(method, bean, cliCommand.help()); + Supplier availabilityIndicator = bridgeAvailabilityIndicator(key, bean); + MethodTarget target = new MethodTarget(method, bean, cliCommand.help(), availabilityIndicator); registry.register(key, target); commands.put(key, target); } @@ -64,6 +67,28 @@ public class LegacyMethodTargetRegistrar implements MethodTargetRegistrar { } } + private Supplier bridgeAvailabilityIndicator(String commandKey, Object bean) { + Class clazz = bean.getClass(); + Set candidates = new HashSet<>(); + + ReflectionUtils.doWithMethods(clazz, candidates::add, + method -> method.getAnnotation(CliAvailabilityIndicator.class) != null + && Arrays.asList(method.getAnnotation(CliAvailabilityIndicator.class).value()).contains(commandKey)); + + switch (candidates.size()) { + case 0: + return null; + case 1: + return () -> { + boolean available = (Boolean) ReflectionUtils.invokeMethod(candidates.iterator().next(), bean); + return available ? Availability.available() : Availability.unavailable("[Unknown reason]"); + }; + default: + throw new IllegalStateException("Looks like there are several @" + CliAvailabilityIndicator.class.getSimpleName() + + " for '" + commandKey + "'. Found " + candidates); + } + } + @Override public String toString() { return getClass().getSimpleName() + " contributing " diff --git a/spring-shell-shell1-adapter/src/test/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrarTest.java b/spring-shell-shell1-adapter/src/test/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrarTest.java index ca88eb96..5799da5b 100644 --- a/spring-shell-shell1-adapter/src/test/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrarTest.java +++ b/spring-shell-shell1-adapter/src/test/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrarTest.java @@ -56,7 +56,7 @@ public class LegacyMethodTargetRegistrarTest { assertThat(targets).contains(entry( "register module", - new MethodTarget(LegacyCommands.REGISTER_METHOD, legacyCommands, "Register a new module" ) + MethodTarget.of("register", legacyCommands, "Register a new module") )); } 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 9fc24c96..8fc9a743 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 @@ -31,15 +31,11 @@ import org.jline.utils.AttributedStringBuilder; import org.jline.utils.AttributedStyle; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.shell.MethodTarget; -import org.springframework.shell.ParameterDescription; -import org.springframework.shell.ParameterResolver; -import org.springframework.shell.CommandRegistry; +import org.springframework.shell.*; import org.springframework.shell.standard.CommandValueProvider; import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; import org.springframework.shell.standard.ShellOption; -import org.springframework.shell.Utils; /** * A command to display help about all available commands. @@ -204,6 +200,14 @@ public class Help { } } + Availability availability = methodTarget.getAvailability(); + if (!availability.isAvailable()) { + result.append("CURRENTLY UNAVAILABLE", AttributedStyle.BOLD).append("\n"); + result.append('\t').append("This command is currently not available because ") + .append(availability.getReason()) + .append(".\n"); + } + result.append("\n"); return result; } @@ -223,12 +227,18 @@ public class Help { groupedByMethodTarget.entrySet().stream() .sorted(sortByFirstElement()) - .forEach(e -> result.append("\t") + .forEach(e -> result.append(isAvailable(e) ? " " : " * ") .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")); + return result.append("\n"); } @@ -236,6 +246,11 @@ public class Help { return Comparator.comparing(e -> e.getValue().iterator().next()); } + private boolean isAvailable(Map.Entry> entry) { + String commandName = entry.getValue().iterator().next(); + return commandRegistry.listCommands().get(commandName).getAvailability().isAvailable(); + } + private void appendUnderlinedFormal(AttributedStringBuilder result, ParameterDescription description) { for (char c : description.formal().toCharArray()) { if (c != ' ') { 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 04d3abeb..81df4ea0 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 @@ -96,18 +96,15 @@ public class HelpTest { public CommandRegistry shell() { return () -> { Map result = new HashMap<>(); - Method method = ReflectionUtils.findMethod(Commands.class, "firstCommand", boolean.class, boolean.class, int.class, float[].class); - MethodTarget methodTarget = new MethodTarget(method, commands(), "A rather extensive description of some command."); + MethodTarget methodTarget = MethodTarget.of("firstCommand", commands(), "A rather extensive description of some command."); result.put("first-command", methodTarget); result.put("1st-command", methodTarget); - method = ReflectionUtils.findMethod(Commands.class, "secondCommand"); - methodTarget = new MethodTarget(method, commands(), "The second command. This one is known under several aliases as well."); + methodTarget = MethodTarget.of("secondCommand", commands(), "The second command. This one is known under several aliases as well."); result.put("second-command", methodTarget); result.put("yet-another-command", methodTarget); - method = ReflectionUtils.findMethod(Commands.class, "thirdCommand"); - methodTarget = new MethodTarget(method, commands(), "The last command."); + methodTarget = MethodTarget.of("thirdCommand", commands(), "The last command."); result.put("third-command", methodTarget); return result; diff --git a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt index edfba6ca..faad7e9a 100644 --- a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt +++ b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt @@ -1,6 +1,6 @@ AVAILABLE COMMANDS& & - 1st-command, first-command: A rather extensive description of some command.& - second-command, yet-another-command: The second command. This one is known under several aliases as well.& - third-command: The last command.& + 1st-command, first-command: A rather extensive description of some command.& + second-command, yet-another-command: The second command. This one is known under several aliases as well.& + third-command: The last command.& & diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellMethodAvailability.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellMethodAvailability.java new file mode 100644 index 00000000..10f93a43 --- /dev/null +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellMethodAvailability.java @@ -0,0 +1,48 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.standard; + +import java.lang.annotation.*; + +/** + * Used to customize the name of the method used to indicate availability of a command. + * + * In the absence of this annotation, the dynamic availability of a command method named {@literal foo} + * is discovered via method {@literal fooAvailability}. + *
    + *
  • If this annotation is added to the {@literal foo} + * method, then its {@link #value()} should be the name of an availability method (in place of + * {@literal fooAvailability()}) that returns {@link org.springframework.shell.Availability}.
  • + *
  • If placed on a method that returns {@link org.springframework.shell.Availability} and takes no argument, + * then the {@link #value()} of this annotation should be the command names (or aliases) of the + * commands this availability indicator is for. The special value of {@literal "*"} (the default) matches + * all commands implemented in the current class.
  • + *
+ * + * @author Eric Bottard + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +@Documented +public @interface ShellMethodAvailability { + + /** + * @return the name of the availability method for this command method, or if placed on an availability method, the names of + * the commands it is for. + */ + String[] value() default "*"; +} diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java index e408aeb9..7dd47786 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java @@ -18,20 +18,22 @@ package org.springframework.shell.standard; import static org.springframework.util.StringUtils.collectionToDelimitedString; -import java.util.HashMap; -import java.util.Map; +import java.lang.reflect.Method; +import java.util.*; +import java.util.function.Supplier; +import java.util.stream.Collector; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; -import org.springframework.shell.ConfigurableCommandRegistry; -import org.springframework.shell.MethodTarget; -import org.springframework.shell.MethodTargetRegistrar; -import org.springframework.shell.Utils; +import org.springframework.shell.*; +import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; /** - * The standard implementation of {@link MethodTargetRegistrar} for new shell applications, - * resolves methods annotated with {@link ShellMethod} on {@link ShellComponent} beans. + * The standard implementation of {@link MethodTargetRegistrar} for new shell + * applications, resolves methods annotated with {@link ShellMethod} on + * {@link ShellComponent} beans. * * @author Eric Bottard * @author Florent Biville @@ -39,11 +41,15 @@ import org.springframework.util.ReflectionUtils; */ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar { - @Autowired private ApplicationContext applicationContext; private Map commands = new HashMap<>(); - + + @Autowired + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + @Override public void register(ConfigurableCommandRegistry registry) { Map commandBeans = applicationContext.getBeansWithAnnotation(ShellComponent.class); @@ -53,10 +59,11 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar { ShellMethod shellMapping = method.getAnnotation(ShellMethod.class); String[] keys = shellMapping.key(); if (keys.length == 0) { - keys = new String[] {Utils.unCamelify(method.getName())}; + keys = new String[] { Utils.unCamelify(method.getName()) }; } for (String key : keys) { - MethodTarget target = new MethodTarget(method, bean, shellMapping.value()); + Supplier availabilityIndicator = findAvailabilityIndicator(keys, bean, method); + MethodTarget target = new MethodTarget(method, bean, shellMapping.value(), availabilityIndicator); registry.register(key, target); commands.put(key, target); } @@ -64,9 +71,90 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar { } } + /** + * Tries to locate an availability indicator (a no-arg method that returns + * {@link Availability}) for the given command method. The following are tried in order + * for method {@literal m}: + *
    + *
  1. If {@literal m} bears the {@literal @}{@link ShellMethodAvailability} annotation, + * its value should be the method name to look up
  2. + *
  3. a method named {@literal "Availability"} is looked up.
  4. + *
  5. otherwise, if some method {@literal ai} that returns {@link Availability} and takes + * no argument exists, that is annotated with {@literal @}{@link ShellMethodAvailability} + * and whose annotation value contains one of the {@literal commandKeys}, then it is + * selected
  6. + *
+ */ + private Supplier findAvailabilityIndicator(String[] commandKeys, Object bean, Method method) { + ShellMethodAvailability explicit = method.getAnnotation(ShellMethodAvailability.class); + final Method indicator; + if (explicit != null) { + Assert.isTrue(explicit.value().length == 1, "When set on a @" + + ShellMethod.class.getSimpleName() + " method, the value of the @" + + ShellMethodAvailability.class.getSimpleName() + + " should be a single element, the name of a method that returns " + + Availability.class.getSimpleName() + + ". Found " + Arrays.asList(explicit.value()) + " for " + method); + indicator = ReflectionUtils.findMethod(bean.getClass(), explicit.value()[0]); + } // Try "Availability" + else { + Method implicit = ReflectionUtils.findMethod(bean.getClass(), method.getName() + "Availability"); + if (implicit != null) { + indicator = implicit; + } else { + Map> candidates = new HashMap<>(); + ReflectionUtils.doWithMethods(bean.getClass(), candidate -> { + List matchKeys = new ArrayList<>(Arrays.asList(candidate.getAnnotation(ShellMethodAvailability.class).value())); + if (matchKeys.contains("*")) { + Assert.isTrue(matchKeys.size() == 1, "When using '*' as a wildcard for " + + ShellMethodAvailability.class.getSimpleName() + ", this can be the only value. Found " + + matchKeys + " on method " + candidate); + candidates.put(candidate, matchKeys); + } else { + matchKeys.retainAll(Arrays.asList(commandKeys)); + if (!matchKeys.isEmpty()) { + candidates.put(candidate, matchKeys); + } + } + }, m -> m.getAnnotation(ShellMethodAvailability.class) != null && m.getAnnotation(ShellMethod.class) == null); + + // Make sure wildcard approach has less precedence than explicit name + Set notUsingWildcard = candidates.entrySet().stream() + .filter(e -> !e.getValue().contains("*")) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + + Assert.isTrue(notUsingWildcard.size() <= 1, + "Found several @" + ShellMethodAvailability.class.getSimpleName() + + " annotated methods that could apply for " + method + ". Offending candidates are " + + notUsingWildcard); + + if (notUsingWildcard.size() == 1) { + indicator = notUsingWildcard.iterator().next(); + } // Wildcard was available + else if (candidates.size() == 1) { + indicator = candidates.keySet().iterator().next(); + } else { + indicator = null; + } + } + } + + if (indicator != null) { + Assert.isTrue(indicator.getReturnType().equals(Availability.class), + "Method " + indicator + " should return " + Availability.class.getSimpleName()); + Assert.isTrue(indicator.getParameterCount() == 0, "Method " + indicator + " should be a no-arg method"); + ReflectionUtils.makeAccessible(indicator); + return () -> (Availability) ReflectionUtils.invokeMethod(indicator, bean); + } + else { + return null; + } + } + @Override public String toString() { return getClass().getSimpleName() + " contributing " - + collectionToDelimitedString(commands.keySet(), ", ", "[", "]"); + + collectionToDelimitedString(commands.keySet(), ", ", "[", "]"); } } diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java new file mode 100644 index 00000000..b5cc2198 --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java @@ -0,0 +1,245 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.standard; + +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.shell.Availability; +import org.springframework.shell.ConfigurableCommandRegistry; +import org.springframework.shell.MethodTarget; +import org.springframework.util.ReflectionUtils; + +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertThat; + +/** + * Unit tests for {@link StandardMethodTargetRegistrar}. + * + * @author Eric Bottard + */ +public class StandardMethodTargetRegistrarTest { + + private StandardMethodTargetRegistrar registrar = new StandardMethodTargetRegistrar(); + private ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void testRegistrations() { + ApplicationContext applicationContext = new AnnotationConfigApplicationContext(Sample.class); + registrar.setApplicationContext(applicationContext); + registrar.register(registry); + + MethodTarget methodTarget = registry.listCommands().get("say-hello"); + assertThat(methodTarget, notNullValue()); + assertThat(methodTarget.getHelp(), is("some command")); + assertThat(methodTarget.getMethod(), is(ReflectionUtils.findMethod(Sample.class, "sayHello", String.class))); + assertThat(methodTarget.getAvailability().isAvailable(), is(true)); + + methodTarget = registry.listCommands().get("hi"); + assertThat(methodTarget, notNullValue()); + assertThat(methodTarget.getHelp(), is("method with alias")); + assertThat(methodTarget.getMethod(), is(ReflectionUtils.findMethod(Sample.class, "greet", String.class))); + assertThat(methodTarget.getAvailability().isAvailable(), is(true)); + methodTarget = registry.listCommands().get("alias"); + assertThat(methodTarget, notNullValue()); + assertThat(methodTarget.getHelp(), is("method with alias")); + assertThat(methodTarget.getMethod(), is(ReflectionUtils.findMethod(Sample.class, "greet", String.class))); + assertThat(methodTarget.getAvailability().isAvailable(), is(true)); + } + + @ShellComponent + public static class Sample { + + @ShellMethod("some command") + public String sayHello(String what) { + return "hello " + what; + } + + @ShellMethod(value = "method with alias", key = {"hi", "alias"}) + public String greet(String what) { + return "hi " + what; + } + } + + @Test + public void testAvailabilityIndicators() { + ApplicationContext applicationContext = new AnnotationConfigApplicationContext(SampleWithAvailability.class); + registrar.setApplicationContext(applicationContext); + registrar.register(registry); + SampleWithAvailability sample = applicationContext.getBean(SampleWithAvailability.class); + + MethodTarget methodTarget = registry.listCommands().get("say-hello"); + assertThat(methodTarget.getMethod(), is(ReflectionUtils.findMethod(SampleWithAvailability.class, "sayHello"))); + assertThat(methodTarget.getAvailability().isAvailable(), is(true)); + sample.available = false; + assertThat(methodTarget.getAvailability().isAvailable(), is(false)); + assertThat(methodTarget.getAvailability().getReason(), is("sayHelloAvailability")); + sample.available = true; + + methodTarget = registry.listCommands().get("hi"); + assertThat(methodTarget.getMethod(), is(ReflectionUtils.findMethod(SampleWithAvailability.class, "hi"))); + assertThat(methodTarget.getAvailability().isAvailable(), is(true)); + sample.available = false; + assertThat(methodTarget.getAvailability().isAvailable(), is(false)); + assertThat(methodTarget.getAvailability().getReason(), is("customAvailabilityMethod")); + sample.available = true; + + methodTarget = registry.listCommands().get("bonjour"); + assertThat(methodTarget.getMethod(), is(ReflectionUtils.findMethod(SampleWithAvailability.class, "bonjour"))); + assertThat(methodTarget.getAvailability().isAvailable(), is(true)); + sample.available = false; + assertThat(methodTarget.getAvailability().isAvailable(), is(false)); + assertThat(methodTarget.getAvailability().getReason(), is("availabilityForSeveralCommands")); + sample.available = true; + } + + @ShellComponent + public static class SampleWithAvailability { + + private boolean available = true; + + @ShellMethod("some command with an implicit availability indicator") + public void sayHello() { + + } + public Availability sayHelloAvailability() { + return available ? Availability.available() : Availability.unavailable("sayHelloAvailability"); + } + + + @ShellMethodAvailability("customAvailabilityMethod") + @ShellMethod("some method with an explicit availability indicator") + public void hi() { + + } + public Availability customAvailabilityMethod() { + return available ? Availability.available() : Availability.unavailable("customAvailabilityMethod"); + } + + @ShellMethod(value = "some method with an explicit availability indicator", key = {"bonjour", "salut"}) + public void bonjour() { + + } + @ShellMethodAvailability({"salut", "other"}) + public Availability availabilityForSeveralCommands() { + return available ? Availability.available() : Availability.unavailable("availabilityForSeveralCommands"); + } + + + @ShellMethod("a command whose availability indicator will come from wildcard") + public void wild() { + + } + + @ShellMethodAvailability("*") + private Availability availabilityFromWildcard() { + return available ? Availability.available() : Availability.unavailable("availabilityFromWildcard"); + } + } + + @Test + public void testAvailabilityIndicatorErrorMultipleExplicit() { + ApplicationContext applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorOnShellMethod.class); + registrar.setApplicationContext(applicationContext); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("When set on a @ShellMethod method, the value of the @ShellMethodAvailability should be a single element"); + thrown.expectMessage("Found [one, two]"); + thrown.expectMessage("wrong()"); + + registrar.register(registry); + } + + @ShellComponent + public static class WrongAvailabilityIndicatorOnShellMethod { + + @ShellMethodAvailability({"one", "two"}) + @ShellMethod("foo") + public void wrong() { + + } + } + + @Test + public void testAvailabilityIndicatorWildcardNotAlone() { + ApplicationContext applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorWildcardNotAlone.class); + registrar.setApplicationContext(applicationContext); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("When using '*' as a wildcard for ShellMethodAvailability, this can be the only value. Found [one, *]"); + thrown.expectMessage("availability()"); + + registrar.register(registry); + } + + @ShellComponent + public static class WrongAvailabilityIndicatorWildcardNotAlone { + + @ShellMethodAvailability({"one", "*"}) + public Availability availability() { + return Availability.available(); + } + + @ShellMethod("foo") + public void wrong() { + + } + } + + @Test + public void testAvailabilityIndicatorAmbiguous() { + ApplicationContext applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorAmbiguous.class); + registrar.setApplicationContext(applicationContext); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Found several @ShellMethodAvailability"); + thrown.expectMessage("wrong()"); + thrown.expectMessage("availability()"); + thrown.expectMessage("otherAvailability()"); + + registrar.register(registry); + } + + @ShellComponent + public static class WrongAvailabilityIndicatorAmbiguous { + + @ShellMethodAvailability({"one", "wrong"}) + public Availability availability() { + return Availability.available(); + } + + @ShellMethodAvailability({"bar", "wrong"}) + public Availability otherAvailability() { + return Availability.available(); + } + + @ShellMethod("foo") + public void wrong() { + + } + } + +} \ No newline at end of file