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
This commit is contained in:
Eric Bottard
2017-08-22 18:22:51 +02:00
parent 6c231a072c
commit 1eea04ad2f
16 changed files with 658 additions and 45 deletions

View File

@@ -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}.
* <ul>
* <li>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}.</li>
* <li>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 <em>command names</em> (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.</li>
* </ul>
*
* @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 "*";
}

View File

@@ -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<String, MethodTarget> commands = new HashMap<>();
@Autowired
public void setApplicationContext(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public void register(ConfigurableCommandRegistry registry) {
Map<String, Object> 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<Availability> 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}:
* <ol>
* <li>If {@literal m} bears the {@literal @}{@link ShellMethodAvailability} annotation,
* its value should be the method name to look up</li>
* <li>a method named {@literal "<m>Availability"} is looked up.</li>
* <li>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</li>
* </ol>
*/
private Supplier<Availability> 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 "<method>Availability"
else {
Method implicit = ReflectionUtils.findMethod(bean.getClass(), method.getName() + "Availability");
if (implicit != null) {
indicator = implicit;
} else {
Map<Method, Collection<String>> candidates = new HashMap<>();
ReflectionUtils.doWithMethods(bean.getClass(), candidate -> {
List<String> 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<Method> 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(), ", ", "[", "]");
}
}

View File

@@ -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() {
}
}
}