From bf2692c70cf567da6055ee62e9952a238c7ef61a Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Sun, 1 Jan 2023 14:51:05 +0000 Subject: [PATCH] Support exception handling with annotated methods - New annotations ExceptionResolver and ExitCode - New needed functionality is in classes ExceptionResolverMethodResolver and MethodCommandExceptionResolver. - Hook these annotations with StandardMethodTargetRegistrar and Shell classes - Fixes #597 --- .../java/org/springframework/shell/Shell.java | 24 ++- .../shell/command/CommandHandlingResult.java | 20 +- .../command/annotation/ExceptionResolver.java | 44 +++++ .../ExceptionResolverMethodResolver.java | 186 ++++++++++++++++++ .../shell/command/annotation/ExitCode.java | 55 ++++++ .../MethodCommandExceptionResolver.java | 130 ++++++++++++ .../ExceptionResolverMethodResolverTests.java | 62 ++++++ .../MethodCommandExceptionResolverTests.java | 128 ++++++++++++ .../invocation/InvocableShellMethodTests.java | 44 +++++ ...commands-exceptionhandling-annotation.adoc | 87 ++++++++ ...l-commands-exceptionhandling-mappings.adoc | 34 ++++ ...-commands-exceptionhandling-resolving.adoc | 47 +++++ ...sing-shell-commands-exceptionhandling.adoc | 77 +------- .../shell/docs/ErrorHandlingSnippets.java | 74 ++++++- .../samples/e2e/ErrorHandlingCommands.java | 84 +++++++- .../StandardMethodTargetRegistrar.java | 9 + 16 files changed, 1012 insertions(+), 93 deletions(-) create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolverMethodResolver.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExitCode.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/command/annotation/MethodCommandExceptionResolver.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/command/annotation/ExceptionResolverMethodResolverTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/command/annotation/MethodCommandExceptionResolverTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/command/invocation/InvocableShellMethodTests.java create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-annotation.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-mappings.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-resolving.adoc diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java index 2301b920..9b4a4350 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java @@ -252,7 +252,12 @@ public class Shell { return ute.getCause(); } catch (CommandExecutionException e1) { - return e1.getCause(); + if (e1.getCause() instanceof Exception e11) { + e = e11; + } + else { + return e1.getCause(); + } } catch (Exception e2) { e = e2; @@ -265,9 +270,13 @@ public class Shell { CommandHandlingResult processException = processException(commandExceptionResolvers, e); processExceptionNonInt = processException; if (processException != null) { - handlingResultNonInt = e; - this.terminal.writer().append(processException.message()); - this.terminal.writer().flush(); + if (processException.isPresent()) { + handlingResultNonInt = e; + if (StringUtils.hasText(processException.message())) { + this.terminal.writer().append(processException.message()); + this.terminal.writer().flush(); + } + } return null; } } catch (Exception e1) { @@ -298,12 +307,7 @@ public class Shell { } } if (r != null) { - if (r.isEmpty()) { - return null; - } - else { - return r; - } + return r; } throw e; } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandHandlingResult.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandHandlingResult.java index 8cdd275e..000565a0 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandHandlingResult.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandHandlingResult.java @@ -25,7 +25,7 @@ import org.springframework.lang.Nullable; public interface CommandHandlingResult { /** - * Gets a message for this {@code HandlingResult}. + * Gets a message for this {@code CommandHandlingResult}. * * @return a message */ @@ -33,7 +33,7 @@ public interface CommandHandlingResult { String message(); /** - * Gets an exit code for this {@code HandlingResult}. Exit code only has meaning + * Gets an exit code for this {@code CommandHandlingResult}. Exit code only has meaning * if shell is in non-interactive mode. * * @return an exit code @@ -41,44 +41,44 @@ public interface CommandHandlingResult { Integer exitCode(); /** - * Indicate whether this {@code HandlingResult} has a result. + * Indicate whether this {@code CommandHandlingResult} has a result. * * @return true if result exist */ public boolean isPresent(); /** - * Indicate whether this {@code HandlingResult} does not have a result. + * Indicate whether this {@code CommandHandlingResult} does not have a result. * * @return true if result doesn't exist */ public boolean isEmpty(); /** - * Gets an empty instance of {@code HandlingResult}. + * Gets an empty instance of {@code CommandHandlingResult}. * - * @return empty instance of {@code HandlingResult} + * @return empty instance of {@code CommandHandlingResult} */ public static CommandHandlingResult empty() { return of(null); } /** - * Gets an instance of {@code HandlingResult}. + * Gets an instance of {@code CommandHandlingResult}. * * @param message the message - * @return instance of {@code HandlingResult} + * @return instance of {@code CommandHandlingResult} */ public static CommandHandlingResult of(@Nullable String message) { return of(message, null); } /** - * Gets an instance of {@code HandlingResult}. + * Gets an instance of {@code CommandHandlingResult}. * * @param message the message * @param exitCode the exit code - * @return instance of {@code HandlingResult} + * @return instance of {@code CommandHandlingResult} */ public static CommandHandlingResult of(@Nullable String message, Integer exitCode) { return new DefaultHandlingResult(message, exitCode); 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 new file mode 100644 index 00000000..4c0a5ca4 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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.aot.hint.annotation.Reflective; + +/** + * Annotation for handling exceptions in specific command classes and/or its methods. + * + * @author Janne Valkealahti + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@Documented +@Reflective +public @interface ExceptionResolver { + + /** + * Exceptions handled by the annotated method. If empty, will default to any + * exceptions listed in the method argument list. + * + * @return Exceptions handled by annotated method + */ + Class[] value() default {}; +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolverMethodResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolverMethodResolver.java new file mode 100644 index 00000000..7c8e60e9 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolverMethodResolver.java @@ -0,0 +1,186 @@ +/* + * Copyright 2022 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.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.core.ExceptionDepthComparator; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.ReflectionUtils.MethodFilter; + +/** + * + * @author Janne Valkealahti + */ +public class ExceptionResolverMethodResolver { + + private static final MethodFilter EXCEPTION_HANDLER_METHODS = method -> + AnnotatedElementUtils.hasAnnotation(method, ExceptionResolver.class); + private static final Method NO_MATCHING_EXCEPTION_HANDLER_METHOD; + private final Map, Method> mappedMethods = new HashMap<>(16); + private final Map, Method> exceptionLookupCache = new ConcurrentReferenceHashMap<>(16); + + static { + try { + NO_MATCHING_EXCEPTION_HANDLER_METHOD = + ExceptionResolverMethodResolver.class.getDeclaredMethod("noMatchingExceptionHandler"); + } + catch (NoSuchMethodException ex) { + throw new IllegalStateException("Expected method not found: " + ex); + } + } + + /** + * A constructor that finds {@link ExceptionResolver} methods in the given type. + * + * @param handlerType the type to introspect + */ + public ExceptionResolverMethodResolver(Class handlerType) { + for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHODS)) { + for (Class exceptionType : detectExceptionMappings(method)) { + addExceptionMapping(exceptionType, method); + } + } + } + + /** + * Extract exception mappings from the {@code @ExceptionResolver} annotation first, + * and then as a fallback from the method signature itself. + */ + @SuppressWarnings("unchecked") + private List> detectExceptionMappings(Method method) { + List> result = new ArrayList<>(); + detectAnnotationExceptionMappings(method, result); + if (result.isEmpty()) { + for (Class paramType : method.getParameterTypes()) { + if (Throwable.class.isAssignableFrom(paramType)) { + result.add((Class) paramType); + } + } + } + if (result.isEmpty()) { + throw new IllegalStateException("No exception types mapped to " + method); + } + return result; + } + + private void detectAnnotationExceptionMappings(Method method, List> result) { + ExceptionResolver ann = AnnotatedElementUtils.findMergedAnnotation(method, ExceptionResolver.class); + Assert.state(ann != null, "No ExceptionResolver annotation"); + result.addAll(Arrays.asList(ann.value())); + } + + private void addExceptionMapping(Class exceptionType, Method method) { + Method oldMethod = this.mappedMethods.put(exceptionType, method); + if (oldMethod != null && !oldMethod.equals(method)) { + throw new IllegalStateException("Ambiguous @ExceptionResolver method mapped for [" + + exceptionType + "]: {" + oldMethod + ", " + method + "}"); + } + } + + /** + * Whether the contained type has any exception mappings. + */ + public boolean hasExceptionMappings() { + return !this.mappedMethods.isEmpty(); + } + + /** + * Find a {@link Method} to handle the given exception. + *

Uses {@link ExceptionDepthComparator} if more than one match is found. + * @param exception the exception + * @return a Method to handle the exception, or {@code null} if none found + */ + @Nullable + public Method resolveMethod(Exception exception) { + return resolveMethodByThrowable(exception); + } + + /** + * Find a {@link Method} to handle the given Throwable. + *

Uses {@link ExceptionDepthComparator} if more than one match is found. + * + * @param exception the exception + * @return a Method to handle the exception, or {@code null} if none found + */ + @Nullable + public Method resolveMethodByThrowable(Throwable exception) { + Method method = resolveMethodByExceptionType(exception.getClass()); + if (method == null) { + Throwable cause = exception.getCause(); + if (cause != null) { + method = resolveMethodByThrowable(cause); + } + } + return method; + } + + /** + * Find a {@link Method} to handle the given exception type. This can be + * useful if an {@link Exception} instance is not available (e.g. for tools). + *

Uses {@link ExceptionDepthComparator} if more than one match is found. + * + * @param exceptionType the exception type + * @return a Method to handle the exception, or {@code null} if none found + */ + @Nullable + public Method resolveMethodByExceptionType(Class exceptionType) { + Method method = this.exceptionLookupCache.get(exceptionType); + if (method == null) { + method = getMappedMethod(exceptionType); + this.exceptionLookupCache.put(exceptionType, method); + } + return (method != NO_MATCHING_EXCEPTION_HANDLER_METHOD ? method : null); + } + + /** + * Return the {@link Method} mapped to the given exception type, or + * {@link #NO_MATCHING_EXCEPTION_HANDLER_METHOD} if none. + */ + private Method getMappedMethod(Class exceptionType) { + List> matches = new ArrayList<>(); + for (Class mappedException : this.mappedMethods.keySet()) { + if (mappedException.isAssignableFrom(exceptionType)) { + matches.add(mappedException); + } + } + if (!matches.isEmpty()) { + if (matches.size() > 1) { + matches.sort(new ExceptionDepthComparator(exceptionType)); + } + return this.mappedMethods.get(matches.get(0)); + } + else { + return NO_MATCHING_EXCEPTION_HANDLER_METHOD; + } + } + + /** + * For the {@link #NO_MATCHING_EXCEPTION_HANDLER_METHOD} constant. + */ + @SuppressWarnings("unused") + private void noMatchingExceptionHandler() { + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExitCode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExitCode.java new file mode 100644 index 00000000..70a25288 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExitCode.java @@ -0,0 +1,55 @@ +/* + * Copyright 2022 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.aot.hint.annotation.Reflective; +import org.springframework.core.annotation.AliasFor; + +/** + * Defines exit code. Typically used with {@link ExceptionResolver}. + * + * @author Janne Valkealahti + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@Documented +@Reflective +public @interface ExitCode { + + /** + * Exit code value. + * + * @return exit code + * @see #code() + */ + @AliasFor("code") + int value() default 0; + + /** + * Exit code value. + * + * @return exit code + * @see #value() + */ + @AliasFor("value") + int code() default 0; +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/MethodCommandExceptionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/MethodCommandExceptionResolver.java new file mode 100644 index 00000000..f5d03f4c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/MethodCommandExceptionResolver.java @@ -0,0 +1,130 @@ +/* + * Copyright 2022 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.reflect.Method; +import java.util.ArrayList; + +import org.jline.terminal.Terminal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.shell.command.CommandExceptionResolver; +import org.springframework.shell.command.CommandHandlingResult; +import org.springframework.shell.command.invocation.InvocableShellMethod; +import org.springframework.shell.command.invocation.ShellMethodArgumentResolverComposite; +import org.springframework.util.Assert; + +public class MethodCommandExceptionResolver implements CommandExceptionResolver { + + private final static Logger log = LoggerFactory.getLogger(MethodCommandExceptionResolver.class); + private final Object bean; + private final Terminal terminal; + + public MethodCommandExceptionResolver(Object bean) { + this(bean, null); + } + + public MethodCommandExceptionResolver(Object bean, Terminal terminal) { + Assert.notNull(bean, "Target bean must be set"); + this.bean = bean; + this.terminal = terminal; + } + + @Override + public CommandHandlingResult resolve(Exception ex) { + try { + ExceptionResolverMethodResolver resolver = new ExceptionResolverMethodResolver(bean.getClass()); + Method exceptionResolverMethod = resolver.resolveMethodByThrowable(ex); + if (exceptionResolverMethod == null) { + return null; + } + InvocableShellMethod invocable = new InvocableShellMethod(bean, exceptionResolverMethod); + + ShellMethodArgumentResolverComposite argumentResolvers = new ShellMethodArgumentResolverComposite(); + argumentResolvers.addResolver(new TerminalResolver()); + invocable.setMessageMethodArgumentResolvers(argumentResolvers); + + ArrayList exceptions = new ArrayList<>(); + Throwable exToExpose = ex; + while (exToExpose != null) { + exceptions.add(exToExpose); + Throwable cause = exToExpose.getCause(); + exToExpose = (cause != exToExpose ? cause : null); + } + Object[] arguments = new Object[exceptions.size() + 1]; + exceptions.toArray(arguments); + + MessageBuilder messageBuilder = MessageBuilder.withPayload(arguments); + messageBuilder.setHeader("terminal", terminal); + + MethodParameter returnType = invocable.getReturnType(); + Class parameterType = returnType.getParameterType(); + boolean isVoid = void.class.isAssignableFrom(parameterType); + Object invoke = invocable.invoke(messageBuilder.build(), arguments); + + Integer ecFromAnn = null; + ExitCode ecAnn = AnnotationUtils.findAnnotation(exceptionResolverMethod, ExitCode.class); + if (ecAnn != null && ecAnn.code() > 0) { + ecFromAnn = ecAnn.code(); + } + + if (isVoid) { + if (ecFromAnn != null) { + return CommandHandlingResult.of(null, ecFromAnn); + } + return CommandHandlingResult.empty(); + } + else if (invoke instanceof CommandHandlingResult result) { + if (ecFromAnn != null) { + return CommandHandlingResult.of(result.message(), ecFromAnn); + } + return (CommandHandlingResult)invoke; + } + else if (invoke instanceof String msg) { + if (ecFromAnn != null) { + return CommandHandlingResult.of(msg, ecFromAnn); + } + return CommandHandlingResult.of(msg, 1); + } + } + catch (Exception e) { + // TODO: should think how to report this to user without logging + log.warn("Failure in @ExceptionResolver", e); + } + return null; + } + + private static class TerminalResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return Terminal.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) throws Exception { + Terminal terminal = message.getHeaders().get("terminal", Terminal.class); + return terminal; + } + + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/annotation/ExceptionResolverMethodResolverTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/annotation/ExceptionResolverMethodResolverTests.java new file mode 100644 index 00000000..1c8e3398 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/annotation/ExceptionResolverMethodResolverTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2022 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.io.IOException; + +import org.junit.jupiter.api.Test; + +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExceptionResolverMethodResolverTests { + + @Test + void resolvesFromAnnotation() { + ExceptionResolverMethodResolver resolver = new ExceptionResolverMethodResolver(InAnnotation.class); + assertThat(resolver.hasExceptionMappings()).isTrue(); + assertThat(resolver.resolveMethod(new RuntimeException())) + .isSameAs(ReflectionUtils.findMethod(InAnnotation.class, "errorHandler")); + assertThat(resolver.resolveMethod(new IOException())) + .isSameAs(ReflectionUtils.findMethod(InAnnotation.class, "errorHandler")); + } + + private static class InAnnotation { + + @ExceptionResolver({ RuntimeException.class, IOException.class }) + void errorHandler() { + } + } + + @Test + void resolvesFromMethodParameters() { + ExceptionResolverMethodResolver resolver = new ExceptionResolverMethodResolver(InMethodParameter.class); + assertThat(resolver.hasExceptionMappings()).isTrue(); + assertThat(resolver.resolveMethod(new RuntimeException())).isSameAs(ReflectionUtils + .findMethod(InMethodParameter.class, "errorHandler", RuntimeException.class, IOException.class)); + assertThat(resolver.resolveMethod(new IOException())).isSameAs(ReflectionUtils + .findMethod(InMethodParameter.class, "errorHandler", RuntimeException.class, IOException.class)); + } + + private static class InMethodParameter { + + @ExceptionResolver + void errorHandler(RuntimeException e1, IOException e2) { + } + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/annotation/MethodCommandExceptionResolverTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/annotation/MethodCommandExceptionResolverTests.java new file mode 100644 index 00000000..b5db4d76 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/annotation/MethodCommandExceptionResolverTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2022 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.io.IOException; + +import org.junit.jupiter.api.Test; + +import org.springframework.shell.command.CommandHandlingResult; + +import static org.assertj.core.api.Assertions.assertThat; + +class MethodCommandExceptionResolverTests { + + @Test + void annoHaveMatchingParameter() { + AnnoHaveMatchingParameter bean = new AnnoHaveMatchingParameter(); + MethodCommandExceptionResolver resolver = new MethodCommandExceptionResolver(bean); + CustomException1 e = new CustomException1(); + CommandHandlingResult result = resolver.resolve(e); + assertThat(result).isNotNull(); + assertThat(e).isSameAs(bean.called); + } + + private static class AnnoHaveMatchingParameter { + + Exception called; + + @ExceptionResolver({ CustomException1.class }) + CommandHandlingResult errorHandler(Exception e) { + called = e; + return CommandHandlingResult.of("Hi, handled exception\n", 42); + } + } + + @Test + void annoHaveNotMatchingParameter() { + AnnoHaveNotMatchingParameter bean = new AnnoHaveNotMatchingParameter(); + MethodCommandExceptionResolver resolver = new MethodCommandExceptionResolver(bean); + CustomException1 e = new CustomException1(); + CommandHandlingResult result = resolver.resolve(e); + assertThat(result).isNotNull(); + assertThat(bean.called).isTrue(); + } + + private static class AnnoHaveNotMatchingParameter { + + boolean called; + + @ExceptionResolver({ CustomException1.class }) + CommandHandlingResult errorHandler() { + called = true; + return CommandHandlingResult.of("Hi, handled exception\n", 42); + } + } + + @Test + void resolvedFromParameter() { + ResolvedFromParameter bean = new ResolvedFromParameter(); + MethodCommandExceptionResolver resolver = new MethodCommandExceptionResolver(bean); + CustomException1 e = new CustomException1(); + CommandHandlingResult result = resolver.resolve(e); + assertThat(result).isNotNull(); + assertThat(e).isSameAs(bean.called); + } + + private static class ResolvedFromParameter { + + Exception called; + + @ExceptionResolver + CommandHandlingResult errorHandler(CustomException1 e) { + called = e; + return CommandHandlingResult.of("Hi, handled exception\n", 42); + } + } + + @Test + void noMappedExceptions() { + NoMappedExceptions bean = new NoMappedExceptions(); + MethodCommandExceptionResolver resolver = new MethodCommandExceptionResolver(bean); + assertThat(resolver).isNotNull(); + } + + private static class NoMappedExceptions { + + @ExceptionResolver + CommandHandlingResult errorHandler() { + return RESULT; + } + } + + @Test + void shouldErrorWhenResolving() { + ShouldErrorWhenResolving bean = new ShouldErrorWhenResolving(); + MethodCommandExceptionResolver resolver = new MethodCommandExceptionResolver(bean); + RuntimeException e = new RuntimeException(); + CommandHandlingResult result = resolver.resolve(e); + // Internal error doesn't get through - IOException cannot be resolved + assertThat(result).isNull(); + } + + private static class ShouldErrorWhenResolving { + + @ExceptionResolver + CommandHandlingResult errorHandler(RuntimeException e1, IOException e2) { + return RESULT; + } + } + + private static CommandHandlingResult RESULT = CommandHandlingResult.of("Hi, handled exception\n", 42); + + private static class CustomException1 extends RuntimeException { + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/invocation/InvocableShellMethodTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/invocation/InvocableShellMethodTests.java new file mode 100644 index 00000000..30ae665a --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/invocation/InvocableShellMethodTests.java @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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.invocation; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class InvocableShellMethodTests { + + @Test + public void resolveArg() throws Exception { + Handler bean = new Handler(); + Method method = ReflectionUtils.findMethod(Handler.class, "handle", Integer.class, String.class); + InvocableShellMethod invocable = new InvocableShellMethod(bean, method); + Object value = invocable.invoke(null, 99, "value"); + assertThat(value).isEqualTo("99-value"); + } + + @SuppressWarnings("unused") + private static class Handler { + + public String handle(Integer intArg, String stringArg) { + return intArg + "-" + stringArg; + } + } +} diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-annotation.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-annotation.adoc new file mode 100644 index 00000000..e20a51e4 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-annotation.adoc @@ -0,0 +1,87 @@ +[[dynamic-command-exitcode-annotation]] +==== @ExceptionResolver +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +`@ShellComponent` classes can have `@ExceptionResolver` methods to handle exceptions from component +methods. These are meant for annotated methods. + +The exception may match against a top-level exception being propagated (e.g. a direct IOException +being thrown) or against a nested cause within a wrapper exception (e.g. an IOException wrapped +inside an IllegalStateException). This can match at arbitrary cause levels. + +For matching exception types, preferably declare the target exception as a method argument, as +the preceding example(s) shows. When multiple exception methods match, a root exception match is +generally preferred to a cause exception match. More specifically, the ExceptionDepthComparator +is used to sort exceptions based on their depth from the thrown exception type. + +Alternatively, the annotation declaration may narrow the exception types to match, as the +following example shows: + +==== +[source, java, indent=0] +---- +include::{snippets}/ErrorHandlingSnippets.java[tag=exception-resolver-with-type-in-annotation] +---- +==== + +==== +[source, java, indent=0] +---- +include::{snippets}/ErrorHandlingSnippets.java[tag=exception-resolver-with-type-in-method] +---- +==== + +`@ExceptionResolver` can also return `String` which is used as an output to console. You can +use `@ExitCode` annotation to define return code. + +==== +[source, java, indent=0] +---- +include::{snippets}/ErrorHandlingSnippets.java[tag=exception-resolver-with-exitcode-annotation] +---- +==== + +`@ExceptionResolver` with `void` return type is automatically handled as handled exception. +You can then also define `@ExitCode` and use `Terminal` if you need to write something +into console. + +==== +[source, java, indent=0] +---- +include::{snippets}/ErrorHandlingSnippets.java[tag=exception-resolver-with-void] +---- +==== + +===== Method Arguments +`@ExceptionResolver` methods support the following arguments: + +[Attributes] +|=== +|Method argument |Description + +|Exception type +|For access to the raised exception. This is any type of `Exception` or `Throwable`. + +|Terminal +|For access to underlying `JLine` terminal to i.e. get its terminal writer. + +|=== + +===== Return Values +`@ExceptionResolver` methods support the following return values: + +[Attributes] +|=== +|Return value |Description + +|String +|Plain text to return to a shell. Exit code 1 is used in this case. + +|CommandHandlingResult +|Plain `CommandHandlingResult` having message and exit code. + +|void +|A method with a void return type is considered to have fully handled the exception. Usually +you would define `Terminal` as a method argument and write response using _terminal writer_ +from it. As exception is fully handled, Exit code 0 is used in this case. +|=== diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-mappings.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-mappings.adoc new file mode 100644 index 00000000..951e9f2b --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-mappings.adoc @@ -0,0 +1,34 @@ +[[dynamic-command-exitcode-mappings]] +==== Exit Code Mappings +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Default behaviour of an exit codes is as: + +- Errors from a command option parsing will result code of `2` +- Any generic error will result result code of `1` +- Obviously in any other case result code is `0` + +Every `CommandRegistration` can define its own mappings between _Exception_ and _exit code_. +Essentially we're bound to functionality in `Spring Boot` regarding _exit code_ and simply +integrate into that. + +Assuming there is an exception show below which would be thrown from a command: + +==== +[source, java, indent=0] +---- +include::{snippets}/ExitCodeSnippets.java[tag=my-exception-class] +---- +==== + +It is possible to define a mapping function between `Throwable` and exit code. You can also +just configure a _class_ to _exit code_ which is just a syntactic sugar within configurations. + +==== +[source, java, indent=0] +---- +include::{snippets}/ExitCodeSnippets.java[tag=example1] +---- +==== + +NOTE: Exit codes cannot be customized with annotation based configuration diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-resolving.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-resolving.adoc new file mode 100644 index 00000000..f85af205 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling-resolving.adoc @@ -0,0 +1,47 @@ +[[dynamic-command-exitcode-resolving]] +==== Exception Resolving +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Unhandled exceptions will bubble up into shell's `ResultHandlerService` and then eventually +handled by some instance of `ResultHandler`. Chain of `ExceptionResolver` implementations +can be used to resolve exceptions and gives you flexibility to return message to get written +into console together with exit code which are wrapped within `CommandHandlingResult`. +`CommandHandlingResult` may contain a _message_ and/or _exit code_. + +==== +[source, java, indent=0] +---- +include::{snippets}/ErrorHandlingSnippets.java[tag=my-exception-resolver-class] +---- +==== + +`CommandExceptionResolver` implementations can be defined globally as bean. + +==== +[source, java, indent=0] +---- +include::{snippets}/ErrorHandlingSnippets.java[tag=my-exception-resolver-class-as-bean] +---- +==== + +or defined per `CommandRegistration` if it's applicable only for a particular command itself. + +==== +[source, java, indent=0] +---- +include::{snippets}/ErrorHandlingSnippets.java[tag=example1] +---- +==== + +NOTE: Resolvers defined with a command are handled before global resolvers. + + +Use you own exception types which can also be an instance of boot's `ExitCodeGenerator` if +you want to define exit code there. + +==== +[source, java, indent=0] +---- +include::{snippets}/ErrorHandlingSnippets.java[tag=my-exception-class] +---- +==== diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling.adoc index 6a79d5c9..60b7b867 100644 --- a/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling.adoc +++ b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling.adoc @@ -2,77 +2,18 @@ === Exception Handling ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] +Exceptions happen from a user code wether it is intentional or not. This section describes +how `spring-shell` handles exceptions and gives instructions and best practices how to +work with it. + Many command line applications when applicable return an _exit code_ which running environment can use to differentiate if command has been executed successfully or not. In a `spring-shell` this mostly relates when a command is run on a non-interactive mode meaning one command -is always executed once with an instance of a `spring-shell`. +is always executed once with an instance of a `spring-shell`. Take a note that _exit code_ +always relates to non-interactive shell. -==== Exception Resolving +include::using-shell-commands-exceptionhandling-resolving.adoc[] -Unhandled exceptions will bubble up into shell's `ResultHandlerService` and then eventually -handled by some instance of `ResultHandler`. Chain of `ExceptionResolver` implementations -can be used to resolve exceptions and gives you flexibility to return message to get written -into console together with exit code which are wrapped within `CommandHandlingResult`. - -==== -[source, java, indent=0] ----- -include::{snippets}/ErrorHandlingSnippets.java[tag=my-exception-resolver-class] ----- -==== - -`CommandExceptionResolver` implementations can be defined globally as beans or defined -per `CommandRegistration` if it's applicable only for a particular command itself. - -==== -[source, java, indent=0] ----- -include::{snippets}/ErrorHandlingSnippets.java[tag=example1] ----- -==== - -Use you own exception types which can also be an instance of boot's `ExitCodeGenerator` if -you want to define exit code there. - -==== -[source, java, indent=0] ----- -include::{snippets}/ErrorHandlingSnippets.java[tag=my-exception-class] ----- -==== - -NOTE: With annotation based configuration exception resolving can only be customised globally - -==== Exit Code Mappings - -Default behaviour of an exit codes is as: - -- Errors from a command option parsing will result code of `2` -- Any generic error will result result code of `1` -- Obviously in any other case result code is `0` - -Every `CommandRegistration` can define its own mappings between _Exception_ and _exit code_. -Essentially we're bound to functionality in `Spring Boot` regarding _exit code_ and simply -integrate into that. - -Assuming there is an exception show below which would be thrown from a command: - -==== -[source, java, indent=0] ----- -include::{snippets}/ExitCodeSnippets.java[tag=my-exception-class] ----- -==== - -It is possible to define a mapping function between `Throwable` and exit code. You can also -just configure a _class_ to _exit code_ which is just a syntactic sugar within configurations. - -==== -[source, java, indent=0] ----- -include::{snippets}/ExitCodeSnippets.java[tag=example1] ----- -==== - -NOTE: Exit codes cannot be customized with annotation based configuration +include::using-shell-commands-exceptionhandling-mappings.adoc[] +include::using-shell-commands-exceptionhandling-annotation.adoc[] diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/ErrorHandlingSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/ErrorHandlingSnippets.java index c934b83f..a7ae0440 100644 --- a/spring-shell-docs/src/test/java/org/springframework/shell/docs/ErrorHandlingSnippets.java +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/ErrorHandlingSnippets.java @@ -15,14 +15,28 @@ */ package org.springframework.shell.docs; -import org.springframework.shell.command.CommandRegistration; +import java.io.PrintWriter; + +import org.jline.terminal.Terminal; + +import org.springframework.boot.ExitCodeGenerator; +import org.springframework.context.annotation.Bean; import org.springframework.shell.command.CommandExceptionResolver; import org.springframework.shell.command.CommandHandlingResult; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.command.annotation.ExceptionResolver; +import org.springframework.shell.command.annotation.ExitCode; class ErrorHandlingSnippets { // tag::my-exception-class[] - static class CustomException extends RuntimeException {} + static class CustomException extends RuntimeException implements ExitCodeGenerator { + + @Override + public int getExitCode() { + return 0; + } + } // end::my-exception-class[] // tag::my-exception-resolver-class[] @@ -48,4 +62,60 @@ class ErrorHandlingSnippets { // end::example1[] } + static class Dump1 { + + // tag::exception-resolver-with-type-in-annotation[] + @ExceptionResolver({ RuntimeException.class }) + CommandHandlingResult errorHandler(Exception e) { + // Exception would be type of RuntimeException, + // optionally do something with it + return CommandHandlingResult.of("Hi, handled exception\n", 42); + } + // end::exception-resolver-with-type-in-annotation[] + } + + static class Dump2 { + + // tag::exception-resolver-with-type-in-method[] + @ExceptionResolver + CommandHandlingResult errorHandler(RuntimeException e) { + return CommandHandlingResult.of("Hi, handled custom exception\n", 42); + } + // end::exception-resolver-with-type-in-method[] + } + + static class Dump3 { + + // tag::my-exception-resolver-class-as-bean[] + @Bean + CustomExceptionResolver customExceptionResolver() { + return new CustomExceptionResolver(); + } + // end::my-exception-resolver-class-as-bean[] + } + + static class Dump4 { + + // tag::exception-resolver-with-exitcode-annotation[] + @ExceptionResolver + @ExitCode(code = 5) + String errorHandler(Exception e) { + return "Hi, handled exception"; + } + // end::exception-resolver-with-exitcode-annotation[] + } + + static class Dump5 { + + // tag::exception-resolver-with-void[] + @ExceptionResolver + @ExitCode(code = 5) + void errorHandler(Exception e, Terminal terminal) { + PrintWriter writer = terminal.writer(); + String msg = "Hi, handled exception " + e.toString(); + writer.println(msg); + writer.flush(); + } + // end::exception-resolver-with-void[] + } } diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ErrorHandlingCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ErrorHandlingCommands.java index 7bc9b7ee..29fd4eae 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ErrorHandlingCommands.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ErrorHandlingCommands.java @@ -15,14 +15,21 @@ */ package org.springframework.shell.samples.e2e; +import java.io.IOException; +import java.io.PrintWriter; import java.util.function.Supplier; +import org.jline.terminal.Terminal; + import org.springframework.boot.ExitCodeGenerator; import org.springframework.context.annotation.Bean; -import org.springframework.shell.command.CommandRegistration; import org.springframework.shell.command.CommandExceptionResolver; import org.springframework.shell.command.CommandHandlingResult; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.command.annotation.ExceptionResolver; +import org.springframework.shell.command.annotation.ExitCode; import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; /** * Commands used for e2e test. @@ -32,8 +39,55 @@ import org.springframework.shell.standard.ShellComponent; @ShellComponent public class ErrorHandlingCommands extends BaseE2ECommands { + @ShellMethod(key = LEGACY_ANNO + "error-handling", group = GROUP) + String testErrorHandling(String arg1) throws IOException { + if ("throw1".equals(arg1)) { + throw new CustomException1(); + } + if ("throw2".equals(arg1)) { + throw new CustomException2(11); + } + if ("throw3".equals(arg1)) { + throw new RuntimeException(); + } + if ("throw4".equals(arg1)) { + throw new IllegalArgumentException(); + } + if ("throw5".equals(arg1)) { + throw new CustomException3(); + } + if ("throw6".equals(arg1)) { + throw new CustomException4(); + } + return "Hello " + arg1; + } + + @ExceptionResolver({ CustomException1.class }) + CommandHandlingResult errorHandler1(CustomException1 e) { + return CommandHandlingResult.of("Hi, handled custom exception\n", 42); + } + + @ExceptionResolver + CommandHandlingResult errorHandler2(IllegalArgumentException e) { + return CommandHandlingResult.of("Hi, handled illegal exception\n", 42); + } + + @ExceptionResolver({ CustomException3.class }) + @ExitCode(3) + String errorHandler3(CustomException3 e) { + return "Hi, handled custom exception 3\n"; + } + + @ExceptionResolver({ CustomException4.class }) + @ExitCode(code = 4) + void errorHandler3(CustomException4 e, Terminal terminal) { + PrintWriter writer = terminal.writer(); + writer.println(String.format("Hi, handled custom exception %s", e)); + writer.flush(); + } + @Bean - public CommandRegistration testErrorHandlingRegistration(Supplier builder) { + CommandRegistration testErrorHandlingRegistration(Supplier builder) { return builder.get() .command(REG, "error-handling") .group(GROUP) @@ -56,6 +110,16 @@ public class ErrorHandlingCommands extends BaseE2ECommands { if ("throw3".equals(arg1)) { throw new RuntimeException(); } + if ("throw4".equals(arg1)) { + throw new IllegalArgumentException(); + } + if ("throw5".equals(arg1)) { + throw new CustomException3(); + } + if ("throw6".equals(arg1)) { + throw new CustomException4(); + } + return "Hello " + arg1; }) .and() @@ -79,13 +143,27 @@ public class ErrorHandlingCommands extends BaseE2ECommands { } } + private static class CustomException3 extends RuntimeException { + } + + private static class CustomException4 extends RuntimeException { + } private static class CustomExceptionResolver implements CommandExceptionResolver { @Override public CommandHandlingResult resolve(Exception e) { if (e instanceof CustomException1) { - return CommandHandlingResult.of("Hi, handled exception\n", 42); + return CommandHandlingResult.of("Hi, handled custom exception\n", 42); + } + if (e instanceof CustomException3) { + return CommandHandlingResult.of("Hi, handled custom exception 3\n", 3); + } + if (e instanceof CustomException4) { + return CommandHandlingResult.of("Hi, handled custom exception\n", 42); + } + if (e instanceof IllegalArgumentException) { + return CommandHandlingResult.of("Hi, handled illegal exception\n", 42); } return null; } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java index 49e04c61..6d34fb55 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java @@ -26,9 +26,11 @@ import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; +import org.jline.terminal.Terminal; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.MethodParameter; @@ -43,6 +45,7 @@ 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.MethodCommandExceptionResolver; import org.springframework.shell.completion.CompletionResolver; import org.springframework.shell.standard.ShellOption.NoValueProvider; import org.springframework.util.Assert; @@ -211,6 +214,12 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar { } builder.withTarget().method(bean, method); + + ObjectProvider terminal = this.applicationContext.getBeanProvider(Terminal.class); + // TODO: feels a bit fishy to return null terminal but for now it's mostly to pass tests as it should not fail + MethodCommandExceptionResolver resolver = new MethodCommandExceptionResolver(bean, terminal.getIfAvailable(() -> null)); + builder.withErrorHandling().resolver(resolver); + CommandRegistration registration = builder.build(); registry.register(registration); }, method -> method.getAnnotation(ShellMethod.class) != null);