New annotation model

- This is a first commit to add new annotation model
  which eventually will replace old legacy annotations
  like ShellComponent, ShellMethod, @ShellOption, etc.
- Adds subset of features needed for parity with manual
  use of CommandRegistration.
- Relates #637
- Relates #638
- Relates #639
- Relates #640
- Relates #641
This commit is contained in:
Janne Valkealahti
2023-01-27 12:04:21 +00:00
parent d1c482cd49
commit de1a3baf18
22 changed files with 1781 additions and 15 deletions

View File

@@ -0,0 +1,157 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.shell.context.InteractionMode;
/**
* Annotation marking a method to be a candicate for a shell command target.
*
* @author Janne Valkealahti
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.METHOD })
@Documented
public @interface Command {
/**
* Define command as an array. Given that command should be
* {@code command1 sub1} it can be defined as:
*
* <pre class="code">
* command = { "command1", "sub1" }
* command = "command1 sub1"
* </pre>
*
* Values are split and trimmed meaning spaces doesn't matter.
*
* <p>
* <b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level mappings inherit this primary
* command to use it as a prefix.
*
* <pre class="code">
* &#64;Command(command = "command1")
* class MyCommands {
*
* &#64;Command(command = "sub1")
* void sub1(){}
* }
* </pre>
*
* @return the command as an array
*/
String[] command() default {};
/**
* Define alias as an array. Given that alias should be
* {@code alias1 sub1} it can be defined as:
*
* <pre class="code">
* command = { "alias1", "sub1" }
* command = "alias1 sub1"
* </pre>
*
* Values are split and trimmed meaning spaces doesn't matter.
*
* <p>
* <b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level mappings inherit this primary
* alias to use it as a prefix.
*
* <pre class="code">
* &#64;Command(alias = "alias1")
* class MyCommands {
*
* &#64;Command(alias = "sub1")
* void sub1(){}
* }
* </pre>
*
* @return the aliases as an array
*/
String[] alias() default {};
/**
* Define a command group.
*
* <p>
* <b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level group inherit this primary
* group. Can be overridden on method-level.
*
* @return the command group
*/
String group() default "";
/**
* Define a command description.
*
* <p>
* <b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level descriptions inherit this primary
* field. Can be overridden on method-level.
*
* @return the command description
*/
String description() default "";
/**
* Define command to be hidden.
*
* <p>
* <b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level mappings inherit this primary
* hidden field.
*
* <pre class="code">
* &#64;Command(hidden = true)
* class MyCommands {
*
* &#64;Command
* void sub1(){
* // sub1 command is hidden
* }
* }
* </pre>
*
* @return true if command should be hidden
*/
boolean hidden() default false;
/**
* Define interaction mode for a command as a hint when command should be
* available. For example presense of some commands doesn't make sense if shell
* is running as non-interactive mode and vice versa.
*
* <p>
* <b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level mappings inherit this primary
* field.
*
* Type is an array to be able to indicate that default don't have anyting defined.
*
* @return interaction modes
*/
InteractionMode[] interactionMode() default {};
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.AliasFor;
import org.springframework.shell.command.annotation.support.CommandScanRegistrar;
/**
* Configures the base packages used when scanning for {@link Command @Comamnd}
* classes. One of {@link #basePackageClasses()}, {@link #basePackages()} or its
* alias {@link #value()} may be specified to define specific packages to scan.
* If specific packages are not defined scanning will occur from the package of
* the class with this annotation.
*
* @author Janne Valkealahti
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(CommandScanRegistrar.class)
@EnableCommand
public @interface CommandScan {
/**
* Alias for the {@link #basePackages()} attribute. Allows for more concise
* annotation declarations e.g.: {@code @CommandScan("org.my.pkg")} instead of
* {@code @CommandScan(basePackages="org.my.pkg")}.
*
* @return the base packages to scan
*/
@AliasFor("basePackages")
String[] value() default {};
/**
* Base packages to scan for commands. {@link #value()} is an alias for (and
* mutually exclusive with) this attribute.
* <p>
* Use {@link #basePackageClasses()} for a type-safe alternative to String-based
* package names.
*
* @return the base packages to scan
*/
@AliasFor("value")
String[] basePackages() default {};
/**
* Type-safe alternative to {@link #basePackages()} for specifying the packages
* to scan for commands. The package of each class specified will be scanned.
* <p>
* Consider creating a special no-op marker class or interface in each package
* that serves no purpose other than being referenced by this attribute.
*
* @return classes from the base packages to scan
*/
Class<?>[] basePackageClasses() default {};
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Import;
import org.springframework.shell.command.annotation.support.EnableCommandRegistrar;
/**
* Enable support for {@link Command @Command} annotated classes.
* {@code @Command} classes can be registered directly on this annotation.
*
* @author Janne Valkealahti
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(EnableCommandRegistrar.class)
public @interface EnableCommand {
/**
* Defines candicate classes for shell commands.
*
* @return candidate classes for shell commands
*/
Class<?>[] value() default {};
}

View File

@@ -21,8 +21,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.aot.hint.annotation.Reflective;
/**
* Annotation for handling exceptions in specific command classes and/or its methods.
*
@@ -31,7 +29,6 @@ import org.springframework.aot.hint.annotation.Reflective;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
@Reflective
public @interface ExceptionResolver {
/**

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.shell.command.CommandRegistration.OptionArity;
/**
* Annotation marking a method parameter to be a candicate for an option.
*
* @author Janne Valkealahti
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@Documented
public @interface Option {
/**
* Long names of an option. There can be multiple names where first is primary
* one and other are aliases.
*
* @return Option long names, defaults to empty.
*/
String[] longNames() default {};
/**
* Short names of an option. There can be multiple names where first is primary
* one and other are aliases.
*
* @return Option short names, defaults to empty.
*/
char[] shortNames() default {};
/**
* Mark option required.
*
* @return true if option is required, defaults to false.
*/
boolean required() default false;
/**
* Define option default value.
*
* @return default value
*/
String defaultValue() default "";
/**
* Return a short description of the option.
*
* @return description of the option
*/
String description() default "";
/**
* Define option arity.
*
* @return option arity
*/
OptionArity arity() default OptionArity.NONE;
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation marking a method parameter which completion proposals should be
* used.
*
* @author Janne Valkealahti
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@Documented
public @interface OptionValues {
/**
* Reference to a bean name
* @return a bean name
*/
String ref() default "";
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation.support;
import java.util.stream.Stream;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.lang.Nullable;
import org.springframework.shell.command.annotation.Command;
import org.springframework.shell.context.InteractionMode;
import org.springframework.util.StringUtils;
/**
* Utilities to merge {@link Command} annotations using opinionated logic. In
* this class {@code left} is meant for annotation on a class level and
* {@code right} annotation on a method level. Class level is meant to provide
* defaults and every field may have its own logic.
*
* @author Janne Valkealahti
*/
class CommandAnnotationUtils {
private final static String COMMAND = "command";
private final static String ALIAS = "alias";
private final static String HIDDEN = "hidden";
private final static String GROUP = "group";
private final static String DESCRIPTION = "description";
private final static String INTERACTION_MODE = "interactionMode";
/**
* Deduce {@link Command#hidden()} from annotations.
*
* @param left the left side annotation
* @param right the right side annotation
* @return deduced boolean for hidden field
*/
static boolean deduceHidden(MergedAnnotation<?> left, MergedAnnotation<?> right) {
Boolean def = right.getDefaultValue(HIDDEN, Boolean.class).orElse(null);
boolean l = left.getBoolean(HIDDEN);
boolean r = right.getBoolean(HIDDEN);
if (def != null) {
if (def != r) {
l = r;
}
}
else {
l = r;
}
return l;
}
/**
* Deduce {@link Command#command()} from annotations. Command array is supposed
* to contain commands without leading or trailing white spaces, so strip, split
* and assume that class level defines prefix for array.
*
* @param left the left side annotation
* @param right the right side annotation
* @return deduced boolean for command field
*/
static String[] deduceCommand(MergedAnnotation<?> left, MergedAnnotation<?> right) {
return deduceStringArray(COMMAND, left, right);
}
/**
* Deduce {@link Command#alias()} from annotations. Alias array is supposed
* to contain commands without leading or trailing white spaces, so strip, split
* and assume that class level defines prefix for array.
*
* @param left the left side annotation
* @param right the right side annotation
* @return deduced boolean for alias field
*/
static String[] deduceAlias(MergedAnnotation<?> left, MergedAnnotation<?> right) {
return deduceStringArray(ALIAS, left, right);
}
/**
* Deduce {@link Command#group()} from annotations. Right side overrides if it
* has value.
*
* @param left the left side annotation
* @param right the right side annotation
* @return deduced String for group field
*/
static String deduceGroup(MergedAnnotation<?> left, MergedAnnotation<?> right) {
return deduceStringRightOverrides(GROUP, left, right);
}
/**
* Deduce {@link Command#description()} from annotations. Right side overrides if it
* has value.
*
* @param left the left side annotation
* @param right the right side annotation
* @return deduced String for description field
*/
static String deduceDescription(MergedAnnotation<?> left, MergedAnnotation<?> right) {
return deduceStringRightOverrides(DESCRIPTION, left, right);
}
/**
* Deduce {@link Command#interactionMode()} from annotations. Right side overrides if.
* Returns {@code null} if nothing defined.
*
* @param left the left side annotation
* @param right the right side annotation
* @return deduced InteractionMode for interaction mode field
*/
static @Nullable InteractionMode deduceInteractionMode(MergedAnnotation<?> left, MergedAnnotation<?> right) {
InteractionMode mode = null;
InteractionMode[] l = left.getEnumArray(INTERACTION_MODE, InteractionMode.class);
for (InteractionMode m : l) {
if (InteractionMode.ALL == m) {
mode = m;
break;
}
else {
mode = m;
}
}
InteractionMode[] r = right.getEnumArray(INTERACTION_MODE, InteractionMode.class);
for (InteractionMode m : r) {
if (InteractionMode.ALL == m) {
mode = m;
break;
}
else {
mode = m;
break;
}
}
return mode;
}
private static String[] deduceStringArray(String field, MergedAnnotation<?> left, MergedAnnotation<?> right) {
return Stream.of(left.getStringArray(field), right.getStringArray(field))
.flatMap(commands -> Stream.of(commands))
.flatMap(command -> Stream.of(command.split(" ")))
.filter(command -> StringUtils.hasText(command))
.map(command -> command.strip())
.toArray(String[]::new);
}
private static String deduceStringRightOverrides(String field, MergedAnnotation<?> left, MergedAnnotation<?> right) {
String r = right.getString(field);
if (StringUtils.hasText(r)) {
return r;
}
return left.getString(field);
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation.support;
import java.lang.reflect.Method;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.HierarchicalBeanFactory;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.core.MethodIntrospector;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
import org.springframework.shell.command.annotation.Command;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils.MethodFilter;
/**
* Delegate used by {@link EnableCommandRegistrar} and
* {@link CommandScanRegistrar} to register a bean definition(s) for a
* {@link Command @Command} class.
*
* @author Janne Valkealahti
*/
public final class CommandRegistrationBeanRegistrar {
private final BeanDefinitionRegistry registry;
private final BeanFactory beanFactory;
private static final MethodFilter COMMAND_METHODS = method ->
AnnotatedElementUtils.hasAnnotation(method, Command.class);
public CommandRegistrationBeanRegistrar(BeanDefinitionRegistry registry) {
this.registry = registry;
this.beanFactory = (BeanFactory) this.registry;
}
public void register(Class<?> type) {
MergedAnnotation<Command> annotation = MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY)
.get(Command.class);
register(type, annotation);
}
void register(Class<?> type, MergedAnnotation<Command> annotation) {
String name = type.getName();
if (!containsBeanDefinition(name)) {
registerCommandClassBeanDefinition(name, type, annotation);
}
scanMethods(type, name, annotation);
}
void scanMethods(Class<?> type, String containerBean, MergedAnnotation<Command> classAnnotation) {
Set<Method> methods = MethodIntrospector.selectMethods(type, COMMAND_METHODS);
methods.forEach(m -> {
String name = type.getName();
String methodName = m.getName();
Class<?>[] methodParameterTypes = m.getParameterTypes();
String postfix = Stream.of(methodParameterTypes).map(clazz -> ClassUtils.getShortName(clazz))
.collect(Collectors.joining());
name = name + "/" + methodName + postfix;
if (!containsBeanDefinition(name)) {
registerCommandMethodBeanDefinition(type, name, containerBean, methodName, methodParameterTypes);
}
});
}
private void registerCommandClassBeanDefinition(String beanName, Class<?> type,
MergedAnnotation<Command> annotation) {
Assert.state(annotation.isPresent(), () -> "No " + Command.class.getSimpleName()
+ " annotation found on '" + type.getName() + "'.");
this.registry.registerBeanDefinition(beanName, createCommandClassBeanDefinition(type));
}
private void registerCommandMethodBeanDefinition(Class<?> commandBeanType, String commandBeanName, String containerBean, String methodName,
Class<?>[] methodParameterTypes) {
this.registry.registerBeanDefinition(commandBeanName,
createCommandMethodBeanDefinition(commandBeanType, containerBean, methodName, methodParameterTypes));
}
private BeanDefinition createCommandClassBeanDefinition(Class<?> type) {
RootBeanDefinition definition = new RootBeanDefinition(type);
return definition;
}
private BeanDefinition createCommandMethodBeanDefinition(Class<?> commandBeanType, String commandBeanName,
String commandMethodName, Class<?>[] commandMethodParameters) {
RootBeanDefinition definition = new RootBeanDefinition(CommandRegistrationFactoryBean.class);
definition.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_BEAN_TYPE, commandBeanType);
definition.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_BEAN_NAME, commandBeanName);
definition.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_METHOD_NAME, commandMethodName);
definition.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_METHOD_PARAMETERS, commandMethodParameters);
return definition;
}
private boolean containsBeanDefinition(String name) {
return containsBeanDefinition(this.beanFactory, name);
}
private boolean containsBeanDefinition(BeanFactory beanFactory, String name) {
if (beanFactory instanceof ListableBeanFactory listableBeanFactory
&& listableBeanFactory.containsBeanDefinition(name)) {
return true;
}
if (beanFactory instanceof HierarchicalBeanFactory hierarchicalBeanFactory) {
return containsBeanDefinition(hierarchicalBeanFactory.getParentBeanFactory(), name);
}
return false;
}
}

View File

@@ -0,0 +1,311 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation.support;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
import org.springframework.messaging.handler.invocation.InvocableHandlerMethod;
import org.springframework.shell.Utils;
import org.springframework.shell.command.CommandExceptionResolver;
import org.springframework.shell.command.CommandHandlingResult;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.CommandRegistration.Builder;
import org.springframework.shell.command.CommandRegistration.OptionArity;
import org.springframework.shell.command.CommandRegistration.OptionSpec;
import org.springframework.shell.command.annotation.Command;
import org.springframework.shell.command.annotation.ExceptionResolverMethodResolver;
import org.springframework.shell.command.annotation.Option;
import org.springframework.shell.command.annotation.OptionValues;
import org.springframework.shell.command.invocation.InvocableShellMethod;
import org.springframework.shell.completion.CompletionProvider;
import org.springframework.shell.context.InteractionMode;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
/**
* Factory bean used in {@link CommandRegistrationBeanRegistrar} to build
* instance of {@link CommandRegistration}. Main logic of constructing
* {@link CommandRegistration} out from annotated command target is
* in this factory.
*
* This factory needs a name of a {@code commandBeanName} which is a name of bean
* hosting command methods, {@code commandBeanType} which is type of a bean,
* {@code commandMethodName} which is a name of {@link Method} in a bean,
* {@code commandMethodParameters} for method parameter types and optionally
* {@code BuilderSupplier} if context provides pre-configured builder.
*
* This is internal class and not meant for generic use.
*
* @author Janne Valkealahti
*/
class CommandRegistrationFactoryBean implements FactoryBean<CommandRegistration>, ApplicationContextAware, InitializingBean {
private final Logger log = LoggerFactory.getLogger(CommandRegistrationFactoryBean.class);
public static final String COMMAND_BEAN_TYPE = "commandBeanType";
public static final String COMMAND_BEAN_NAME = "commandBeanName";
public static final String COMMAND_METHOD_NAME = "commandMethodName";
public static final String COMMAND_METHOD_PARAMETERS = "commandMethodParameters";
private ObjectProvider<CommandRegistration.BuilderSupplier> supplier;
private ApplicationContext applicationContext;
private Object commandBean;
private Class<?> commandBeanType;
private String commandBeanName;
private String commandMethodName;
private Class<?>[] commandMethodParameters;
@Override
public CommandRegistration getObject() throws Exception {
CommandRegistration registration = buildRegistration();
return registration;
}
@Override
public Class<?> getObjectType() {
return CommandRegistration.class;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void afterPropertiesSet() throws Exception {
this.commandBean = applicationContext.getBean(commandBeanName);
this.supplier = applicationContext.getBeanProvider(CommandRegistration.BuilderSupplier.class);
}
public void setCommandBeanType(Class<?> commandBeanType) {
this.commandBeanType = commandBeanType;
}
public void setCommandBeanName(String commandBeanName) {
this.commandBeanName = commandBeanName;
}
public void setCommandMethodName(String commandMethodName) {
this.commandMethodName = commandMethodName;
}
public void setCommandMethodParameters(Class<?>[] commandMethodParameters) {
this.commandMethodParameters = commandMethodParameters;
}
private CommandRegistration.Builder getBuilder() {
return supplier.getIfAvailable(() -> () -> CommandRegistration.builder()).get();
}
private CommandRegistration buildRegistration() {
Method method = ReflectionUtils.findMethod(commandBeanType, commandMethodName, commandMethodParameters);
MergedAnnotation<Command> classAnn = MergedAnnotations.from(commandBeanType, SearchStrategy.TYPE_HIERARCHY)
.get(Command.class);
MergedAnnotation<Command> methodAnn = MergedAnnotations.from(method, SearchStrategy.TYPE_HIERARCHY)
.get(Command.class);
Builder builder = getBuilder();
// command
String[] deduceCommand = CommandAnnotationUtils.deduceCommand(classAnn, methodAnn);
if (deduceCommand.length == 0) {
deduceCommand = new String[] { Utils.unCamelify(method.getName()) };
}
builder.command(deduceCommand);
// group
String deduceGroup = CommandAnnotationUtils.deduceGroup(classAnn, methodAnn);
builder.group(deduceGroup);
// hidden
boolean deduceHidden = CommandAnnotationUtils.deduceHidden(classAnn, methodAnn);
builder.hidden(deduceHidden);
// description
String deduceDescription = CommandAnnotationUtils.deduceDescription(classAnn, methodAnn);
builder.description(deduceDescription);
// interaction mode
InteractionMode deduceInteractionMode = CommandAnnotationUtils.deduceInteractionMode(classAnn, methodAnn);
builder.interactionMode(deduceInteractionMode);
// alias
String[] deduceAlias = CommandAnnotationUtils.deduceAlias(classAnn, methodAnn);
if (deduceAlias.length > 0) {
builder.withAlias().command(deduceAlias);
}
// target
builder.withTarget().method(commandBean, method);
// options
InvocableHandlerMethod ihm = new InvocableHandlerMethod(commandBean, method);
for (MethodParameter mp : ihm.getMethodParameters()) {
onCommandParameter(mp, builder);
}
// error handling
ExceptionResolverMethodResolver exceptionResolverMethodResolver = new ExceptionResolverMethodResolver(commandBean.getClass());
MethodCommandExceptionResolver methodCommandExceptionResolver = new MethodCommandExceptionResolver();
methodCommandExceptionResolver.bean = commandBean;
methodCommandExceptionResolver.exceptionResolverMethodResolver = exceptionResolverMethodResolver;
builder.withErrorHandling().resolver(methodCommandExceptionResolver);
CommandRegistration registration = builder.build();
return registration;
}
private void onCommandParameter(MethodParameter mp, Builder builder) {
MergedAnnotation<Option> optionAnn = MergedAnnotations.from(mp.getParameter(), SearchStrategy.TYPE_HIERARCHY)
.get(Option.class);
Option so = mp.getParameterAnnotation(Option.class);
log.debug("Registering with mp='{}' so='{}'", mp, so);
if (so != null) {
List<String> longNames = new ArrayList<>();
List<Character> shortNames = new ArrayList<>();
for(int i = 0; i < so.shortNames().length; i++) {
shortNames.add(so.shortNames()[i]);
}
if (!ObjectUtils.isEmpty(so.longNames())) {
for(int i = 0; i < so.longNames().length; i++) {
longNames.add(so.longNames()[i]);
}
}
else {
// ShellOption value not defined
mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
String longName = mp.getParameterName();
Class<?> parameterType = mp.getParameterType();
if (longName != null) {
log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType);
longNames.add(longName);
}
}
if (!longNames.isEmpty() || !shortNames.isEmpty()) {
log.debug("Registering longNames='{}' shortNames='{}'", longNames, shortNames);
Class<?> parameterType = mp.getParameterType();
OptionSpec optionSpec = builder.withOption();
optionSpec.type(parameterType);
optionSpec.longNames(longNames.toArray(new String[0]));
optionSpec.shortNames(shortNames.toArray(new Character[0]));
optionSpec.position(mp.getParameterIndex());
optionSpec.description(so.description());
if (so.arity() != OptionArity.NONE) {
optionSpec.arity(so.arity());
}
else {
if (ClassUtils.isAssignable(boolean.class, parameterType)) {
optionSpec.arity(OptionArity.ZERO);
}
else if (ClassUtils.isAssignable(Boolean.class, parameterType)) {
optionSpec.arity(OptionArity.ZERO);
}
else {
optionSpec.arity(OptionArity.EXACTLY_ONE);
}
}
if (StringUtils.hasText(so.defaultValue())) {
optionSpec.defaultValue(so.defaultValue());
}
else if (ClassUtils.isAssignable(boolean.class, parameterType)){
optionSpec.required(false);
optionSpec.defaultValue("false");
}
else {
if (optionAnn.isPresent()) {
boolean requiredDeduce = optionAnn.getBoolean("required");
optionSpec.required(requiredDeduce);
}
}
OptionValues ovAnn = mp.getParameterAnnotation(OptionValues.class);
if (ovAnn != null && StringUtils.hasText(ovAnn.ref())) {
CompletionProvider cr = this.applicationContext.getBean(ovAnn.ref(), CompletionProvider.class);
if (cr != null) {
optionSpec.completion(ctx -> cr.apply(ctx));
}
}
}
}
else {
mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
String longName = mp.getParameterName();
Class<?> parameterType = mp.getParameterType();
if (longName != null) {
log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType);
OptionSpec optionSpec = builder.withOption();
optionSpec.longNames(longName);
optionSpec.type(parameterType);
optionSpec.required();
optionSpec.position(mp.getParameterIndex());
if (ClassUtils.isAssignable(boolean.class, parameterType)) {
optionSpec.arity(OptionArity.ZERO);
}
else if (ClassUtils.isAssignable(Boolean.class, parameterType)) {
optionSpec.arity(OptionArity.ZERO);
}
else {
optionSpec.arity(OptionArity.EXACTLY_ONE);
}
}
}
}
private static class MethodCommandExceptionResolver implements CommandExceptionResolver {
Object bean;
ExceptionResolverMethodResolver exceptionResolverMethodResolver;
@Override
public CommandHandlingResult resolve(Exception ex) {
Method exceptionHandlerMethod = exceptionResolverMethodResolver.resolveMethodByThrowable(ex);
if (exceptionHandlerMethod == null) {
return null;
}
InvocableShellMethod invocable = new InvocableShellMethod(bean, exceptionHandlerMethod);
try {
Object invoke = invocable.invoke(null, ex);
if (invoke instanceof CommandHandlingResult) {
return (CommandHandlingResult)invoke;
}
} catch (Exception e) {
}
return null;
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation.support;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.boot.context.TypeExcludeFilter;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.shell.command.annotation.Command;
import org.springframework.shell.command.annotation.CommandScan;
import org.springframework.stereotype.Component;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
/**
* {@link ImportBeanDefinitionRegistrar} for {@link CommandScan @CommandScan}.
*
* @author Janne Valkealahti
*/
public class CommandScanRegistrar implements ImportBeanDefinitionRegistrar {
private final Environment environment;
private final ResourceLoader resourceLoader;
CommandScanRegistrar(Environment environment, ResourceLoader resourceLoader) {
this.environment = environment;
this.resourceLoader = resourceLoader;
}
@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
Set<String> packagesToScan = getPackagesToScan(importingClassMetadata);
scan(registry, packagesToScan);
}
private Set<String> getPackagesToScan(AnnotationMetadata metadata) {
AnnotationAttributes attributes = AnnotationAttributes
.fromMap(metadata.getAnnotationAttributes(CommandScan.class.getName()));
String[] basePackages = attributes.getStringArray("basePackages");
Class<?>[] basePackageClasses = attributes.getClassArray("basePackageClasses");
Set<String> packagesToScan = new LinkedHashSet<>(Arrays.asList(basePackages));
for (Class<?> basePackageClass : basePackageClasses) {
packagesToScan.add(ClassUtils.getPackageName(basePackageClass));
}
if (packagesToScan.isEmpty()) {
packagesToScan.add(ClassUtils.getPackageName(metadata.getClassName()));
}
packagesToScan.removeIf((candidate) -> !StringUtils.hasText(candidate));
return packagesToScan;
}
private void scan(BeanDefinitionRegistry registry, Set<String> packages) {
CommandRegistrationBeanRegistrar registrar = new CommandRegistrationBeanRegistrar(registry);
ClassPathScanningCandidateComponentProvider scanner = getScanner(registry);
for (String basePackage : packages) {
for (BeanDefinition candidate : scanner.findCandidateComponents(basePackage)) {
register(registrar, candidate.getBeanClassName());
}
}
}
private ClassPathScanningCandidateComponentProvider getScanner(BeanDefinitionRegistry registry) {
ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false);
scanner.setEnvironment(this.environment);
scanner.setResourceLoader(this.resourceLoader);
scanner.addIncludeFilter(new AnnotationTypeFilter(Command.class));
TypeExcludeFilter typeExcludeFilter = new TypeExcludeFilter();
typeExcludeFilter.setBeanFactory((BeanFactory) registry);
scanner.addExcludeFilter(typeExcludeFilter);
return scanner;
}
private void register(CommandRegistrationBeanRegistrar registrar, String className) throws LinkageError {
try {
register(registrar, ClassUtils.forName(className, null));
}
catch (ClassNotFoundException ex) {
// Ignore
}
}
private void register(CommandRegistrationBeanRegistrar registrar, Class<?> type) {
if (!isComponent(type)) {
registrar.register(type);
}
registrar.register(type);
}
private boolean isComponent(Class<?> type) {
return MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY).isPresent(Component.class);
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation.support;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.shell.command.annotation.EnableCommand;
/**
* {@link ImportBeanDefinitionRegistrar} for {@link EnableCommand @EnableCommands}.
*
* @author Janne Valkealahti
*/
public final class EnableCommandRegistrar implements ImportBeanDefinitionRegistrar {
@Override
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
CommandRegistrationBeanRegistrar beanRegistrar = new CommandRegistrationBeanRegistrar(registry);
getTypes(metadata).forEach(beanRegistrar::register);
}
private Set<Class<?>> getTypes(AnnotationMetadata metadata) {
return metadata.getAnnotations().stream(EnableCommand.class)
.flatMap((annotation) -> Arrays.stream(annotation.getClassArray(MergedAnnotation.VALUE)))
.filter((type) -> void.class != type)
.collect(Collectors.toSet());
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2023 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
*
* https://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.completion;
import java.util.List;
import java.util.function.Function;
import org.springframework.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
/**
* Interface resolving completion proposals.
*
* @author Janne Valkealahti
*/
@FunctionalInterface
public interface CompletionProvider extends Function<CompletionContext, List<CompletionProposal>> {
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 the original author or authors.
* Copyright 2022-2023 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.
@@ -15,17 +15,13 @@
*/
package org.springframework.shell.completion;
import java.util.List;
import java.util.function.Function;
import org.springframework.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
/**
* Interface resolving completion proposals.
* Interface resolving completion proposals. This is same as
* {@link CompletionProvider} but mean to be autowired globally.
*
* @author Janne Valkealahti
* @see CompletionProvider
*/
@FunctionalInterface
public interface CompletionResolver extends Function<CompletionContext, List<CompletionProposal>> {
public interface CompletionResolver extends CompletionProvider {
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation.support;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.shell.command.annotation.Command;
import org.springframework.shell.context.InteractionMode;
import static org.assertj.core.api.Assertions.assertThat;
class CommandAnnotationUtilsTests {
private static MergedAnnotation<Command> hiddenTrue = MergedAnnotations.from(HiddenTrue.class).get(Command.class);
private static MergedAnnotation<Command> hiddenFalse = MergedAnnotations.from(HiddenFalse.class).get(Command.class);
private static MergedAnnotation<Command> hiddenDefault = MergedAnnotations.from(HiddenDefault.class)
.get(Command.class);
@Command(hidden = true)
private static class HiddenTrue {
}
@Command(hidden = false)
private static class HiddenFalse {
}
@Command()
private static class HiddenDefault {
}
@Test
void testHidden() {
assertThat(CommandAnnotationUtils.deduceHidden(hiddenTrue, hiddenDefault)).isTrue();
assertThat(CommandAnnotationUtils.deduceHidden(hiddenTrue, hiddenFalse)).isTrue();
assertThat(CommandAnnotationUtils.deduceHidden(hiddenTrue, hiddenTrue)).isTrue();
assertThat(CommandAnnotationUtils.deduceHidden(hiddenFalse, hiddenDefault)).isFalse();
assertThat(CommandAnnotationUtils.deduceHidden(hiddenFalse, hiddenFalse)).isFalse();
assertThat(CommandAnnotationUtils.deduceHidden(hiddenFalse, hiddenTrue)).isTrue();
}
private static MergedAnnotation<Command> commandDefault = MergedAnnotations.from(CommandDefault.class)
.get(Command.class);
private static MergedAnnotation<Command> commandValues1 = MergedAnnotations.from(CommandValues1.class)
.get(Command.class);
private static MergedAnnotation<Command> commandValues2 = MergedAnnotations.from(CommandValues2.class)
.get(Command.class);
private static MergedAnnotation<Command> commandValues3 = MergedAnnotations.from(CommandValues3.class)
.get(Command.class);
private static MergedAnnotation<Command> commandValues4 = MergedAnnotations.from(CommandValues4.class)
.get(Command.class);
@Command
private static class CommandDefault {
}
@Command(command = { "one", "two" })
private static class CommandValues1 {
}
@Command(command = { "three", "four" })
private static class CommandValues2 {
}
@Command(command = { " five", "six ", " seven " })
private static class CommandValues3 {
}
@Command(command = { " eight nine " })
private static class CommandValues4 {
}
@Test
void testCommand() {
assertThat(CommandAnnotationUtils.deduceCommand(commandDefault, commandDefault)).isEmpty();
assertThat(CommandAnnotationUtils.deduceCommand(commandDefault, commandValues1))
.isEqualTo(new String[] { "one", "two" });
assertThat(CommandAnnotationUtils.deduceCommand(commandValues1, commandValues2))
.isEqualTo(new String[] { "one", "two", "three", "four" });
assertThat(CommandAnnotationUtils.deduceCommand(commandDefault, commandValues3))
.isEqualTo(new String[] { "five", "six", "seven" });
assertThat(CommandAnnotationUtils.deduceCommand(commandDefault, commandValues4))
.isEqualTo(new String[] { "eight", "nine" });
}
private static MergedAnnotation<Command> aliasDefault = MergedAnnotations.from(AliasDefault.class)
.get(Command.class);
private static MergedAnnotation<Command> aliasValues1 = MergedAnnotations.from(AliasValues1.class)
.get(Command.class);
private static MergedAnnotation<Command> aliasValues2 = MergedAnnotations.from(AliasValues2.class)
.get(Command.class);
private static MergedAnnotation<Command> aliasValues3 = MergedAnnotations.from(AliasValues3.class)
.get(Command.class);
private static MergedAnnotation<Command> aliasValues4 = MergedAnnotations.from(AliasValues4.class)
.get(Command.class);
@Command
private static class AliasDefault {
}
@Command(alias = { "one", "two" })
private static class AliasValues1 {
}
@Command(alias = { "three", "four" })
private static class AliasValues2 {
}
@Command(alias = { " five", "six ", " seven " })
private static class AliasValues3 {
}
@Command(alias = { " eight nine " })
private static class AliasValues4 {
}
@Test
void testAlias() {
assertThat(CommandAnnotationUtils.deduceAlias(aliasDefault, aliasDefault)).isEmpty();
assertThat(CommandAnnotationUtils.deduceAlias(aliasDefault, aliasValues1))
.isEqualTo(new String[] { "one", "two" });
assertThat(CommandAnnotationUtils.deduceAlias(aliasValues1, aliasValues2))
.isEqualTo(new String[] { "one", "two", "three", "four" });
assertThat(CommandAnnotationUtils.deduceAlias(aliasDefault, aliasValues3))
.isEqualTo(new String[] { "five", "six", "seven" });
assertThat(CommandAnnotationUtils.deduceAlias(aliasDefault, aliasValues4))
.isEqualTo(new String[] { "eight", "nine" });
}
private static MergedAnnotation<Command> groupValue1 = MergedAnnotations.from(GroupValues1.class)
.get(Command.class);
private static MergedAnnotation<Command> groupValue2 = MergedAnnotations.from(GroupValues2.class)
.get(Command.class);
private static MergedAnnotation<Command> groupDefault = MergedAnnotations.from(GroupDefault.class)
.get(Command.class);
@Command(group = "group1")
private static class GroupValues1 {
}
@Command(group = "group2")
private static class GroupValues2 {
}
@Command()
private static class GroupDefault {
}
@Test
void testGroup() {
assertThat(CommandAnnotationUtils.deduceGroup(groupDefault, groupDefault)).isEqualTo("");
assertThat(CommandAnnotationUtils.deduceGroup(groupDefault, groupValue1)).isEqualTo("group1");
assertThat(CommandAnnotationUtils.deduceGroup(groupValue1, groupDefault)).isEqualTo("group1");
assertThat(CommandAnnotationUtils.deduceGroup(groupValue1, groupValue2)).isEqualTo("group2");
}
private static MergedAnnotation<Command> descriptionValue1 = MergedAnnotations.from(DescriptionValues1.class)
.get(Command.class);
private static MergedAnnotation<Command> descriptionValue2 = MergedAnnotations.from(DescriptionValues2.class)
.get(Command.class);
private static MergedAnnotation<Command> descriptionDefault = MergedAnnotations.from(DescriptionDefault.class)
.get(Command.class);
@Command(description = "description1")
private static class DescriptionValues1 {
}
@Command(description = "description2")
private static class DescriptionValues2 {
}
@Command()
private static class DescriptionDefault {
}
@Test
void testDescription() {
assertThat(CommandAnnotationUtils.deduceDescription(descriptionDefault, descriptionDefault)).isEqualTo("");
assertThat(CommandAnnotationUtils.deduceDescription(descriptionDefault, descriptionValue1))
.isEqualTo("description1");
assertThat(CommandAnnotationUtils.deduceDescription(descriptionValue1, descriptionDefault))
.isEqualTo("description1");
assertThat(CommandAnnotationUtils.deduceDescription(descriptionValue1, descriptionValue2))
.isEqualTo("description2");
}
private static MergedAnnotation<Command> interactionModeDefault = MergedAnnotations
.from(InteractionModeDefault.class).get(Command.class);
private static MergedAnnotation<Command> interactionModeAll = MergedAnnotations.from(InteractionModeAll.class)
.get(Command.class);
private static MergedAnnotation<Command> interactionModeInteractive = MergedAnnotations
.from(InteractionModeInteractive.class).get(Command.class);
private static MergedAnnotation<Command> interactionModeNoninteractive = MergedAnnotations
.from(InteractionModeNoninteractive.class).get(Command.class);
@Command()
private static class InteractionModeDefault {
}
@Command(interactionMode = InteractionMode.ALL)
private static class InteractionModeAll {
}
@Command(interactionMode = InteractionMode.INTERACTIVE)
private static class InteractionModeInteractive {
}
@Command(interactionMode = InteractionMode.NONINTERACTIVE)
private static class InteractionModeNoninteractive {
}
@Test
void testInteractionMode() {
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeDefault, interactionModeDefault))
.isNull();
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeAll, interactionModeDefault))
.isEqualTo(InteractionMode.ALL);
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeInteractive, interactionModeDefault))
.isEqualTo(InteractionMode.INTERACTIVE);
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeNoninteractive, interactionModeDefault))
.isEqualTo(InteractionMode.NONINTERACTIVE);
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeDefault, interactionModeAll))
.isEqualTo(InteractionMode.ALL);
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeDefault, interactionModeInteractive))
.isEqualTo(InteractionMode.INTERACTIVE);
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeDefault, interactionModeNoninteractive))
.isEqualTo(InteractionMode.NONINTERACTIVE);
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeAll, interactionModeInteractive))
.isEqualTo(InteractionMode.INTERACTIVE);
assertThat(CommandAnnotationUtils.deduceInteractionMode(interactionModeAll, interactionModeNoninteractive))
.isEqualTo(InteractionMode.NONINTERACTIVE);
assertThat(
CommandAnnotationUtils.deduceInteractionMode(interactionModeInteractive, interactionModeNoninteractive))
.isEqualTo(InteractionMode.NONINTERACTIVE);
assertThat(
CommandAnnotationUtils.deduceInteractionMode(interactionModeNoninteractive, interactionModeInteractive))
.isEqualTo(InteractionMode.INTERACTIVE);
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation.support;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.shell.command.annotation.Command;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
class CommandRegistrationBeanRegistrarTests {
private final BeanDefinitionRegistry registry = new DefaultListableBeanFactory();
private final CommandRegistrationBeanRegistrar registrar = new CommandRegistrationBeanRegistrar(registry);
@Test
void registerWhenNotAlreadyRegisteredAddBeanDefinition() {
String beanName = BeanCommand.class.getName();
this.registrar.register(BeanCommand.class);
BeanDefinition definition = this.registry.getBeanDefinition(beanName);
assertThat(definition).isNotNull();
assertThat(definition.getBeanClassName()).isEqualTo(BeanCommand.class.getName());
}
@Test
void registerWhenNoAnnotationThrowsException() {
assertThatIllegalStateException()
.isThrownBy(() -> this.registrar.register(NoAnnotationCommand.class))
.withMessageContaining("No Command annotation found");
}
@Test
void registerWhenNotAlreadyRegisteredAddMethodBeanDefinition() {
String beanName = BeanWithMethodCommand.class.getName();
this.registrar.register(BeanWithMethodCommand.class);
BeanDefinition definition = this.registry.getBeanDefinition(beanName);
assertThat(definition).isNotNull();
definition = this.registry.getBeanDefinition(beanName + "/method");
assertThat(definition).isNotNull();
assertThat(definition.getBeanClassName()).isEqualTo(CommandRegistrationFactoryBean.class.getName());
}
@Command
static class BeanCommand {
}
@Command
static class BeanWithMethodCommand {
@Command
void method() {
}
}
static class NoAnnotationCommand {
}
}

View File

@@ -0,0 +1,129 @@
/*
* Copyright 2023 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
*
* https://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.command.annotation.support;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.annotation.Command;
import org.springframework.shell.command.annotation.Option;
import static org.assertj.core.api.Assertions.assertThat;
class CommandRegistrationFactoryBeanTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner();
private final static String BEAN = "commandBean";
private final static String FACTORYBEAN = "commandRegistrationFactoryBean";
private final static String FACTORYBEANREF = "&" + FACTORYBEAN;
@Test
void hiddenOnClassLevel() {
configCommon(HiddenOnClassBean.class, new HiddenOnClassBean())
.run((context) -> {
CommandRegistrationFactoryBean fb = context.getBean(FACTORYBEANREF,
CommandRegistrationFactoryBean.class);
assertThat(fb).isNotNull();
CommandRegistration registration = fb.getObject();
assertThat(registration).isNotNull();
assertThat(registration.isHidden()).isTrue();
});
}
@Command(hidden = true)
private static class HiddenOnClassBean {
@Command
void command(){
}
}
@Test
void commandCommonThings() {
configCommon(OnBothClassAndMethod.class, new OnBothClassAndMethod())
.run((context) -> {
CommandRegistrationFactoryBean fb = context.getBean(FACTORYBEANREF,
CommandRegistrationFactoryBean.class);
assertThat(fb).isNotNull();
CommandRegistration registration = fb.getObject();
assertThat(registration).isNotNull();
assertThat(registration.getCommand()).isEqualTo("one two");
assertThat(registration.getAliases()).hasSize(1);
assertThat(registration.getAliases().get(0).getCommand()).isEqualTo("three four");
assertThat(registration.getGroup()).isEqualTo("group2");
});
}
@Command(command = "one", alias = "three", group = "group1")
private static class OnBothClassAndMethod {
@Command(command = "two", alias = "four", group = "group2")
void command(){
}
}
@Test
void setsRequiredOption() {
configCommon(RequiredOption.class, new RequiredOption(), "command1", new Class[] { String.class })
.run((context) -> {
CommandRegistrationFactoryBean fb = context.getBean(FACTORYBEANREF,
CommandRegistrationFactoryBean.class);
assertThat(fb).isNotNull();
CommandRegistration registration = fb.getObject();
assertThat(registration).isNotNull();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).isRequired()).isTrue();
});
configCommon(RequiredOption.class, new RequiredOption(), "command2", new Class[] { String.class })
.run((context) -> {
CommandRegistrationFactoryBean fb = context.getBean(FACTORYBEANREF,
CommandRegistrationFactoryBean.class);
assertThat(fb).isNotNull();
CommandRegistration registration = fb.getObject();
assertThat(registration).isNotNull();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).isRequired()).isFalse();
});
}
@Command
private static class RequiredOption {
@Command
void command1(@Option(required = true) String arg) {
}
@Command
void command2(@Option(required = false) String arg) {
}
}
private <T> ApplicationContextRunner configCommon(Class<T> type, T bean) {
return configCommon(type, bean, "command", new Class[0]);
}
private <T> ApplicationContextRunner configCommon(Class<T> type, T bean, String method, Class<?>[] parameters) {
return this.contextRunner
.withBean(BEAN, type, () -> bean)
.withBean(FACTORYBEAN, CommandRegistrationFactoryBean.class, () -> new CommandRegistrationFactoryBean(), bd -> {
bd.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_BEAN_TYPE, type);
bd.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_BEAN_NAME, BEAN);
bd.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_METHOD_NAME, method);
bd.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_METHOD_PARAMETERS, parameters);
});
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2017-2022 the original author or authors.
* Copyright 2017-2023 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.
@@ -18,10 +18,11 @@ package org.springframework.shell.samples;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStyle;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.Banner.Mode;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.shell.command.annotation.CommandScan;
import org.springframework.shell.jline.PromptProvider;
/**
@@ -33,6 +34,7 @@ import org.springframework.shell.jline.PromptProvider;
* @author Janne Valkealahti
*/
@SpringBootApplication
@CommandScan
public class SpringShellSample {
public static void main(String[] args) throws Exception {

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2023 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
*
* https://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.e2e;
import org.springframework.context.annotation.Bean;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.annotation.Command;
import org.springframework.stereotype.Component;
public class AliasCommands {
@Command(command = BaseE2ECommands.ANNO, alias = BaseE2ECommands.ANNO, group = BaseE2ECommands.GROUP)
public static class AliasCommandsAnnotation extends BaseE2ECommands {
@Command(command = "alias-1", alias = "aliasfor-1")
public String testAlias1Annotation() {
return "Hello from alias command";
}
}
@Component
public static class AliasCommandsRegistration extends BaseE2ECommands {
@Bean
public CommandRegistration testAlias1Registration(CommandRegistration.BuilderSupplier builder) {
return builder.get()
.command(REG, "alias-1")
.group(GROUP)
.withAlias()
.command(REG, "aliasfor-1")
.and()
.withTarget()
.function(ctx -> {
return "Hello from alias command";
})
.and()
.build();
}
}
}

View File

@@ -32,6 +32,8 @@ abstract class BaseE2ECommands {
static final String GROUP = "E2E Commands";
static final String REG = "e2e reg";
static final String LEGACY_ANNO = "e2e anno ";
// TODO: anno should become anno-legacy and annox to anno
static final String ANNO = "e2e annox ";
@Autowired
private CommandRegistration.BuilderSupplier builder;

View File

@@ -23,6 +23,10 @@ import org.springframework.context.annotation.Bean;
import org.springframework.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.annotation.Command;
import org.springframework.shell.command.annotation.Option;
import org.springframework.shell.command.annotation.OptionValues;
import org.springframework.shell.completion.CompletionProvider;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
@@ -42,7 +46,6 @@ public class InteractiveCompletionCommands {
return "Hello " + arg1;
}
@Bean
Test1ValuesProvider test1ValuesProvider() {
return new Test1ValuesProvider();
@@ -54,6 +57,34 @@ public class InteractiveCompletionCommands {
}
}
@Command(command = BaseE2ECommands.ANNO, group = BaseE2ECommands.GROUP)
public static class Annotation extends BaseE2ECommands {
@Command(command = "interactive-completion-1")
public String testRequiredValueAnnotation(
@Option(longNames = "arg1", required = true) @OptionValues(ref = "test1CompletionProvider") String arg1,
@Option(longNames = "arg2", required = true) @OptionValues(ref = "test2CompletionProvider") String arg2
) {
return "Hello " + arg1;
}
@Bean
CompletionProvider test1CompletionProvider() {
return ctx -> {
Test1ValuesProvider test1ValuesProvider = new Test1ValuesProvider();
return test1ValuesProvider.complete(ctx);
};
}
@Bean
CompletionProvider test2CompletionProvider() {
return ctx -> {
Test1ValuesProvider test1ValuesProvider = new Test1ValuesProvider();
return test1ValuesProvider.complete(ctx);
};
}
}
@Component
public static class Registration extends BaseE2ECommands {

View File

@@ -17,6 +17,8 @@ package org.springframework.shell.samples.e2e;
import org.springframework.context.annotation.Bean;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.annotation.Command;
import org.springframework.shell.command.annotation.Option;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
@@ -39,6 +41,17 @@ public class RequiredValueCommands {
return "Hello " + arg1;
}
}
@Command(command = BaseE2ECommands.ANNO, group = BaseE2ECommands.GROUP)
public static class Annotation extends BaseE2ECommands {
@Command(command = "required-value")
public String testRequiredValueAnnotation(
@Option(longNames = "arg1", required = true, description = "Desc arg1")
String arg1
) {
return "Hello " + arg1;
}
}
@Component
public static class Registration extends BaseE2ECommands {

View File

@@ -18,20 +18,25 @@ package org.springframework.shell.samples.e2e;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.shell.command.annotation.EnableCommand;
import org.springframework.shell.samples.AbstractSampleTests;
import org.springframework.shell.samples.e2e.RequiredValueCommands.Annotation;
import org.springframework.shell.samples.e2e.RequiredValueCommands.LegacyAnnotation;
import org.springframework.shell.samples.e2e.RequiredValueCommands.Registration;
import org.springframework.shell.test.ShellTestClient.BaseShellSession;
import org.springframework.test.context.ContextConfiguration;
@ContextConfiguration(classes = {LegacyAnnotation.class, Registration.class})
@EnableCommand(Annotation.class)
class RequiredValueCommandsTests extends AbstractSampleTests {
@ParameterizedTest
@CsvSource({
"e2e anno required-value,false",
"e2e annox required-value,false",
"e2e reg required-value,false",
"e2e anno required-value,true",
"e2e annox required-value,true",
"e2e reg required-value,true"
})
void shouldRequireOption(String command, boolean interactive) {