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
This commit is contained in:
Janne Valkealahti
2023-01-01 14:51:05 +00:00
parent 206da7463b
commit bf2692c70c
16 changed files with 1012 additions and 93 deletions

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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<? extends Throwable>[] value() default {};
}

View File

@@ -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<Class<? extends Throwable>, Method> mappedMethods = new HashMap<>(16);
private final Map<Class<? extends Throwable>, 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<? extends Throwable> 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<Class<? extends Throwable>> detectExceptionMappings(Method method) {
List<Class<? extends Throwable>> result = new ArrayList<>();
detectAnnotationExceptionMappings(method, result);
if (result.isEmpty()) {
for (Class<?> paramType : method.getParameterTypes()) {
if (Throwable.class.isAssignableFrom(paramType)) {
result.add((Class<? extends Throwable>) paramType);
}
}
}
if (result.isEmpty()) {
throw new IllegalStateException("No exception types mapped to " + method);
}
return result;
}
private void detectAnnotationExceptionMappings(Method method, List<Class<? extends Throwable>> 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<? extends Throwable> 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.
* <p>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.
* <p>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).
* <p>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<? extends Throwable> 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<? extends Throwable> exceptionType) {
List<Class<? extends Throwable>> matches = new ArrayList<>();
for (Class<? extends Throwable> 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() {
}
}

View File

@@ -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;
}

View File

@@ -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<Throwable> 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<Object[]> 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;
}
}
}

View File

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

View File

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

View File

@@ -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;
}
}
}

View File

@@ -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.
|===

View File

@@ -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

View File

@@ -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]
----
====

View File

@@ -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[]

View File

@@ -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[]
}
}

View File

@@ -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<CommandRegistration.Builder> builder) {
CommandRegistration testErrorHandlingRegistration(Supplier<CommandRegistration.Builder> 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;
}

View File

@@ -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> 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);