diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Command.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Command.java new file mode 100644 index 00000000..9933b5bf --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Command.java @@ -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: + * + *
+	 * command = { "command1", "sub1" }
+	 * command = "command1 sub1"
+	 * 
+ * + * Values are split and trimmed meaning spaces doesn't matter. + * + *

+ * Supported at the type level as well as at the method level! + * When used at the type level, all method-level mappings inherit this primary + * command to use it as a prefix. + * + *

+	 * @Command(command = "command1")
+	 * class MyCommands {
+	 *
+	 *   @Command(command = "sub1")
+	 *   void sub1(){}
+	 * }
+	 * 
+ * + * @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: + * + *
+	 * command = { "alias1", "sub1" }
+	 * command = "alias1 sub1"
+	 * 
+ * + * Values are split and trimmed meaning spaces doesn't matter. + * + *

+ * Supported at the type level as well as at the method level! + * When used at the type level, all method-level mappings inherit this primary + * alias to use it as a prefix. + * + *

+	 * @Command(alias = "alias1")
+	 * class MyCommands {
+	 *
+	 *   @Command(alias = "sub1")
+	 *   void sub1(){}
+	 * }
+	 * 
+ * + * @return the aliases as an array + */ + String[] alias() default {}; + + /** + * Define a command group. + * + *

+ * Supported at the type level as well as at the method level! + * 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. + * + *

+ * Supported at the type level as well as at the method level! + * 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. + * + *

+ * Supported at the type level as well as at the method level! + * When used at the type level, all method-level mappings inherit this primary + * hidden field. + * + *

+	 * @Command(hidden = true)
+	 * class MyCommands {
+	 *
+	 *   @Command
+	 *   void sub1(){
+	 *     // sub1 command is hidden
+	 *   }
+	 * }
+	 * 
+ * + * @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. + * + *

+ * Supported at the type level as well as at the method level! + * 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 {}; + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/CommandScan.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/CommandScan.java new file mode 100644 index 00000000..bf137fbb --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/CommandScan.java @@ -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. + *

+ * 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. + *

+ * 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 {}; +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/EnableCommand.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/EnableCommand.java new file mode 100644 index 00000000..36dd66ff --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/EnableCommand.java @@ -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 {}; +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java index 4c0a5ca4..87630fc2 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java @@ -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 { /** diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Option.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Option.java new file mode 100644 index 00000000..409542aa --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Option.java @@ -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; +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/OptionValues.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/OptionValues.java new file mode 100644 index 00000000..ebbc272f --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/OptionValues.java @@ -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 ""; +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandAnnotationUtils.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandAnnotationUtils.java new file mode 100644 index 00000000..f857ceff --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandAnnotationUtils.java @@ -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); + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationBeanRegistrar.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationBeanRegistrar.java new file mode 100644 index 00000000..c71a0aa9 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationBeanRegistrar.java @@ -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 annotation = MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY) + .get(Command.class); + register(type, annotation); + } + + void register(Class type, MergedAnnotation annotation) { + String name = type.getName(); + if (!containsBeanDefinition(name)) { + registerCommandClassBeanDefinition(name, type, annotation); + } + scanMethods(type, name, annotation); + } + + void scanMethods(Class type, String containerBean, MergedAnnotation classAnnotation) { + Set 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 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; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java new file mode 100644 index 00000000..fe53b434 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java @@ -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, 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 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 classAnn = MergedAnnotations.from(commandBeanType, SearchStrategy.TYPE_HIERARCHY) + .get(Command.class); + MergedAnnotation 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