From 6e0c588954405e0e6d31569068ecb01537ac09e3 Mon Sep 17 00:00:00 2001
From: Janne Valkealahti
Date: Fri, 14 Oct 2022 10:46:27 +0100
Subject: [PATCH] Implement more flexible error handling
- Add exception handling around new interface CommandExceptionResolver
which allows to define a chain of resolvers to process errors before
exception is bubbled up to result handlers.
- Will be foundation to add more sophisticated error handling features
compared to what spring itself have for rest layer.
- Resolver returns CommandHandlingResult holder which further can be
used to make a choice what to print into console and if spesific exit
code should be used in non-interactive mode.
- Exception handling can be defined globally and per command giving
a change for user to customise i.e. error thrown by parser.
- CommandParserExceptionResolver replaces CommandParserExceptionsExceptionResultHandler
and provides more meaninful message for missing options.
- Fixes #503
---
.vscode/launch.json | 32 ++++
.../test/sample-e2e-exit-code.test.ts | 2 +-
.../test/sample-e2e-required-value.test.ts | 4 +-
.../shell/boot/ExitCodeAutoConfiguration.java | 23 +++
.../java/org/springframework/shell/Shell.java | 172 +++++++++++++-----
.../command/CommandExceptionResolver.java | 36 ++++
.../shell/command/CommandExecution.java | 2 +-
.../shell/command/CommandHandlingResult.java | 121 ++++++++++++
.../shell/command/CommandParser.java | 4 +-
.../CommandParserExceptionResolver.java | 81 +++++++++
.../shell/command/CommandRegistration.java | 82 ++++++++-
.../shell/exit/ExitCodeExceptionProvider.java | 27 +++
...arserExceptionsExceptionResultHandler.java | 30 ---
.../shell/result/ResultHandlerConfig.java | 5 +-
.../command/CommandHandlingResultTests.java | 79 ++++++++
.../CommandParserExceptionResolverTests.java | 97 ++++++++++
.../command/CommandRegistrationTests.java | 30 +++
...ing-shell-commands-exceptionhandling.adoc} | 41 ++++-
.../main/asciidoc/using-shell-commands.adoc | 2 +-
.../shell/docs/ErrorHandlingSnippets.java | 51 ++++++
.../samples/e2e/ErrorHandlingCommands.java | 91 +++++++++
.../samples/e2e/RequiredValueCommands.java | 3 +-
.../samples/e2e/ValidatedValueCommands.java | 66 +++++++
23 files changed, 989 insertions(+), 92 deletions(-)
create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/command/CommandExceptionResolver.java
create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/command/CommandHandlingResult.java
create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/command/CommandParserExceptionResolver.java
create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/exit/ExitCodeExceptionProvider.java
delete mode 100644 spring-shell-core/src/main/java/org/springframework/shell/result/CommandParserExceptionsExceptionResultHandler.java
create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/command/CommandHandlingResultTests.java
create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserExceptionResolverTests.java
rename spring-shell-docs/src/main/asciidoc/{using-shell-commands-exitcode.adoc => using-shell-commands-exceptionhandling.adoc} (54%)
create mode 100644 spring-shell-docs/src/test/java/org/springframework/shell/docs/ErrorHandlingSnippets.java
create mode 100644 spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ErrorHandlingCommands.java
create mode 100644 spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ValidatedValueCommands.java
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 3398db61d..4d83bfb45 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -32,6 +32,38 @@
"projectName": "spring-shell-samples",
"args": "fail --elementType TYPE"
},
+ {
+ "type": "java",
+ "name": "e2e reg error-handling",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "e2e reg error-handling"
+ },
+ {
+ "type": "java",
+ "name": "e2e reg error-handling arg1 throw1",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "e2e reg error-handling --arg1 throw1"
+ },
+ {
+ "type": "java",
+ "name": "e2e reg error-handling arg1 throw2",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "e2e reg error-handling --arg1 throw2"
+ },
+ {
+ "type": "java",
+ "name": "e2e reg error-handling arg1 throw3",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "e2e reg error-handling --arg1 throw3"
+ },
{
"type": "java",
"name": "e2e exit-code noarg",
diff --git a/e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts b/e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts
index def5dc74f..2de8f6928 100644
--- a/e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts
+++ b/e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts
@@ -23,7 +23,7 @@ describe('e2e commands exit-code', () => {
cli.run();
await waitForExpect(async () => {
const screen = cli.screen();
- expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing option')]));
+ expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing mandatory option')]));
});
await expect(cli.exitCode()).resolves.toBe(2);
};
diff --git a/e2e/spring-shell-e2e-tests/test/sample-e2e-required-value.test.ts b/e2e/spring-shell-e2e-tests/test/sample-e2e-required-value.test.ts
index dad309983..8d24a380e 100644
--- a/e2e/spring-shell-e2e-tests/test/sample-e2e-required-value.test.ts
+++ b/e2e/spring-shell-e2e-tests/test/sample-e2e-required-value.test.ts
@@ -23,7 +23,7 @@ describe('e2e commands required-value', () => {
cli.run();
await waitForExpect(async () => {
const screen = cli.screen();
- expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing option')]));
+ expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing mandatory option')]));
});
await expect(cli.exitCode()).resolves.toBe(2);
};
@@ -34,7 +34,7 @@ describe('e2e commands required-value', () => {
cli.run();
await waitForExpect(async () => {
const screen = cli.screen();
- expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing option')]));
+ expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing mandatory option')]));
});
await expect(cli.exitCode()).resolves.toBe(2);
};
diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java
index 9cd84d264..8e22562e2 100644
--- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java
+++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java
@@ -20,11 +20,13 @@
import java.util.function.Function;
import org.springframework.boot.ExitCodeExceptionMapper;
+import org.springframework.boot.ExitCodeGenerator;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.shell.command.CommandExecution;
+import org.springframework.shell.exit.ExitCodeExceptionProvider;
import org.springframework.shell.exit.ExitCodeMappings;
/**
@@ -47,6 +49,12 @@ public ShellExitCodeMappingsExceptionMapper shellExitCodeMappingsExceptionMapper
return new ShellExitCodeMappingsExceptionMapper();
}
+ @Bean
+ @ConditionalOnMissingBean
+ public ExitCodeExceptionProvider exitCodeExceptionProvider() {
+ return (exception, code) -> new ShellExitCodeException(exception, code);
+ }
+
static class ShellExitCodeExceptionMapper implements ExitCodeExceptionMapper {
@Override
@@ -85,4 +93,19 @@ public int getExitCode(Throwable exception) {
return exitCode;
}
}
+
+ static class ShellExitCodeException extends RuntimeException implements ExitCodeGenerator {
+
+ private int code;
+
+ ShellExitCodeException(Throwable throwable, int code) {
+ super(throwable);
+ this.code = code;
+ }
+
+ @Override
+ public int getExitCode() {
+ return code;
+ }
+ }
}
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 a9cd5bd99..b0baf2254 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
@@ -26,7 +26,6 @@
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
-
import org.jline.terminal.Terminal;
import org.jline.utils.Signals;
import org.slf4j.Logger;
@@ -39,13 +38,16 @@
import org.springframework.shell.command.CommandAlias;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.command.CommandExecution;
-import org.springframework.shell.command.CommandOption;
import org.springframework.shell.command.CommandExecution.CommandExecutionException;
import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers;
+import org.springframework.shell.command.CommandOption;
import org.springframework.shell.command.CommandRegistration;
+import org.springframework.shell.command.CommandExceptionResolver;
+import org.springframework.shell.command.CommandHandlingResult;
import org.springframework.shell.completion.CompletionResolver;
import org.springframework.shell.context.InteractionMode;
import org.springframework.shell.context.ShellContext;
+import org.springframework.shell.exit.ExitCodeExceptionProvider;
import org.springframework.shell.exit.ExitCodeMappings;
import org.springframework.util.StringUtils;
@@ -73,6 +75,8 @@ public class Shell {
private ConversionService conversionService = new DefaultConversionService();
private final ShellContext shellContext;
private final ExitCodeMappings exitCodeMappings;
+ private Exception handlingResultNonInt = null;
+ private CommandHandlingResult processExceptionNonInt = null;
/**
* Marker object to distinguish unresolved arguments from {@code null}, which is a valid
@@ -81,6 +85,7 @@ public class Shell {
protected static final Object UNRESOLVED = new Object();
private Validator validator = Utils.defaultValidator();
+ private List exceptionResolvers = new ArrayList<>();
public Shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal,
ShellContext shellContext, ExitCodeMappings exitCodeMappings) {
@@ -111,6 +116,18 @@ public void setValidatorFactory(ValidatorFactory validatorFactory) {
this.validator = validatorFactory.getValidator();
}
+ @Autowired(required = false)
+ public void setExceptionResolvers(List exceptionResolvers) {
+ this.exceptionResolvers = exceptionResolvers;
+ }
+
+ private ExitCodeExceptionProvider exitCodeExceptionProvider;
+
+ @Autowired(required = false)
+ public void setExitCodeExceptionProvider(ExitCodeExceptionProvider exitCodeExceptionProvider) {
+ this.exitCodeExceptionProvider = exitCodeExceptionProvider;
+ }
+
/**
* The main program loop: acquire input, try to match it to a command and evaluate. Repeat
* until a {@link ResultHandler} causes the process to exit or there is no input.
@@ -152,6 +169,13 @@ public void run(InputProvider inputProvider) throws Exception {
else if (result instanceof Exception) {
throw (Exception) result;
}
+ if (handlingResultNonInt instanceof CommandExecution.CommandParserExceptionsException) {
+ throw (CommandExecution.CommandParserExceptionsException) handlingResultNonInt;
+ }
+ else if (processExceptionNonInt != null && processExceptionNonInt.exitCode() != null
+ && exitCodeExceptionProvider != null) {
+ throw exitCodeExceptionProvider.apply(null, processExceptionNonInt.exitCode());
+ }
}
}
}
@@ -165,74 +189,124 @@ else if (result instanceof Exception) {
* result
*
*/
- public Object evaluate(Input input) {
+ private Object evaluate(Input input) {
if (noInput(input)) {
return NO_INPUT;
}
- String line = input.words().stream().collect(Collectors.joining(" ")).trim();
+ List words = input.words();
+ String line = words.stream().collect(Collectors.joining(" ")).trim();
String command = findLongestCommand(line);
- List words = input.words();
+ if (command == null) {
+ return new CommandNotFound(words);
+ }
+
log.debug("Evaluate input with line=[{}], command=[{}]", line, command);
- if (command != null) {
- Optional commandRegistration = commandRegistry.getRegistrations().values().stream()
- .filter(r -> {
- if (r.getCommand().equals(command)) {
+ Optional commandRegistration = commandRegistry.getRegistrations().values().stream()
+ .filter(r -> {
+ if (r.getCommand().equals(command)) {
+ return true;
+ }
+ for (CommandAlias a : r.getAliases()) {
+ if (a.getCommand().equals(command)) {
return true;
}
- for (CommandAlias a : r.getAliases()) {
- if (a.getCommand().equals(command)) {
- return true;
- }
- }
- return false;
- })
- .findFirst();
-
- if (commandRegistration.isPresent()) {
- if (this.exitCodeMappings != null) {
- List> mappingFunctions = commandRegistration.get().getExitCode()
- .getMappingFunctions();
- this.exitCodeMappings.reset(mappingFunctions);
}
+ return false;
+ })
+ .findFirst();
- List wordsForArgs = wordsForArguments(command, words);
+ if (commandRegistration.isEmpty()) {
+ return new CommandNotFound(words);
+ }
- Thread commandThread = Thread.currentThread();
- Object sh = Signals.register("INT", () -> commandThread.interrupt());
- try {
- CommandExecution execution = CommandExecution
- .of(argumentResolvers != null ? argumentResolvers.getResolvers() : null, validator, terminal, conversionService);
- return execution.evaluate(commandRegistration.get(), wordsForArgs.toArray(new String[0]));
- }
- catch (UndeclaredThrowableException e) {
- if (e.getCause() instanceof InterruptedException || e.getCause() instanceof ClosedByInterruptException) {
- Thread.interrupted(); // to reset interrupted flag
- }
- return e.getCause();
- }
- catch (CommandExecutionException e) {
- return e.getCause();
- }
- catch (Exception e) {
- return e;
+ if (this.exitCodeMappings != null) {
+ List> mappingFunctions = commandRegistration.get().getExitCode()
+ .getMappingFunctions();
+ this.exitCodeMappings.reset(mappingFunctions);
+ }
+
+ List wordsForArgs = wordsForArguments(command, words);
+
+ Thread commandThread = Thread.currentThread();
+ Object sh = Signals.register("INT", () -> commandThread.interrupt());
+
+ CommandExecution execution = CommandExecution.of(
+ argumentResolvers != null ? argumentResolvers.getResolvers() : null, validator, terminal,
+ conversionService);
+
+ List commandExceptionResolvers = commandRegistration.get().getExceptionResolvers();
+
+ Object evaluate = null;
+ Exception e = null;
+ try {
+ evaluate = execution.evaluate(commandRegistration.get(), wordsForArgs.toArray(new String[0]));
+ }
+ catch (UndeclaredThrowableException ute) {
+ if (ute.getCause() instanceof InterruptedException || ute.getCause() instanceof ClosedByInterruptException) {
+ Thread.interrupted(); // to reset interrupted flag
+ }
+ return ute.getCause();
+ }
+ catch (CommandExecutionException e1) {
+ return e1.getCause();
+ }
+ catch (Exception e2) {
+ e = e2;
+ }
+ finally {
+ Signals.unregister("INT", sh);
+ }
+ if (e != null) {
+ try {
+ CommandHandlingResult processException = processException(commandExceptionResolvers, e);
+ processExceptionNonInt = processException;
+ if (processException != null) {
+ handlingResultNonInt = e;
+ this.terminal.writer().append(processException.message());
+ this.terminal.writer().flush();
+ return null;
}
- finally {
- Signals.unregister("INT", sh);
+ } catch (Exception e1) {
+ e = e1;
+ }
+ }
+ if (e != null) {
+ evaluate = e;
+ }
+ return evaluate;
+ }
+
+ private CommandHandlingResult processException(List commandExceptionResolvers, Exception e)
+ throws Exception {
+ CommandHandlingResult r = null;
+ for (CommandExceptionResolver resolver : commandExceptionResolvers) {
+ r = resolver.resolve(e);
+ if (r != null) {
+ break;
+ }
+ }
+ if (r == null) {
+ for (CommandExceptionResolver resolver : exceptionResolvers) {
+ r = resolver.resolve(e);
+ if (r != null) {
+ break;
}
}
+ }
+ if (r != null) {
+ if (r.isEmpty()) {
+ return null;
+ }
else {
- return new CommandNotFound(words);
+ return r;
}
}
- else {
- return new CommandNotFound(words);
- }
+ throw e;
}
-
/**
* Return true if the parsed input ends up being empty (e.g. hitting ENTER on an
* empty line or blank space).
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExceptionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExceptionResolver.java
new file mode 100644
index 000000000..2efcb977e
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExceptionResolver.java
@@ -0,0 +1,36 @@
+/*
+ * 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;
+
+/**
+ * Interface to be implemented by objects that can resolve exceptions thrown
+ * during command processing, in the typical case error response. Implementors
+ * are typically registered as beans in the application context or directly
+ * with command.
+ *
+ * @author Janne Valkealahti
+ */
+public interface CommandExceptionResolver {
+
+ /**
+ * Try to resolve the given exception that got thrown during command processing.
+ *
+ * @param ex the exception
+ * @return a corresponding {@code HandlingResult} framework to handle, or
+ * {@code null} for default processing in the resolution chain
+ */
+ CommandHandlingResult resolve(Exception ex);
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java
index 19f989e9e..e22e622ca 100644
--- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java
@@ -201,7 +201,7 @@ public CommandExecutionException(Throwable cause) {
}
}
- static class CommandParserExceptionsException extends RuntimeException {
+ public static class CommandParserExceptionsException extends RuntimeException {
private final List parserExceptions;
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
new file mode 100644
index 000000000..8cdd275e3
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandHandlingResult.java
@@ -0,0 +1,121 @@
+/*
+ * 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;
+
+import org.springframework.lang.Nullable;
+
+/**
+ * Holder for handling some processing, typically with {@link CommandExceptionResolver}.
+ *
+ * @author Janne Valkealahti
+ */
+public interface CommandHandlingResult {
+
+ /**
+ * Gets a message for this {@code HandlingResult}.
+ *
+ * @return a message
+ */
+ @Nullable
+ String message();
+
+ /**
+ * Gets an exit code for this {@code HandlingResult}. Exit code only has meaning
+ * if shell is in non-interactive mode.
+ *
+ * @return an exit code
+ */
+ Integer exitCode();
+
+ /**
+ * Indicate whether this {@code HandlingResult} has a result.
+ *
+ * @return true if result exist
+ */
+ public boolean isPresent();
+
+ /**
+ * Indicate whether this {@code HandlingResult} does not have a result.
+ *
+ * @return true if result doesn't exist
+ */
+ public boolean isEmpty();
+
+ /**
+ * Gets an empty instance of {@code HandlingResult}.
+ *
+ * @return empty instance of {@code HandlingResult}
+ */
+ public static CommandHandlingResult empty() {
+ return of(null);
+ }
+
+ /**
+ * Gets an instance of {@code HandlingResult}.
+ *
+ * @param message the message
+ * @return instance of {@code HandlingResult}
+ */
+ public static CommandHandlingResult of(@Nullable String message) {
+ return of(message, null);
+ }
+
+ /**
+ * Gets an instance of {@code HandlingResult}.
+ *
+ * @param message the message
+ * @param exitCode the exit code
+ * @return instance of {@code HandlingResult}
+ */
+ public static CommandHandlingResult of(@Nullable String message, Integer exitCode) {
+ return new DefaultHandlingResult(message, exitCode);
+ }
+
+ static class DefaultHandlingResult implements CommandHandlingResult {
+
+ private final String message;
+ private final Integer exitCode;
+
+ DefaultHandlingResult(String message) {
+ this(message, null);
+ }
+
+ DefaultHandlingResult(String message, Integer exitCode) {
+ this.message = message;
+ this.exitCode = exitCode;
+ }
+
+ @Override
+ public String message() {
+ return message;
+ }
+
+ @Override
+ public Integer exitCode() {
+ return exitCode;
+ }
+
+ @Override
+ public boolean isPresent() {
+ return message != null || exitCode != null;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return !isPresent();
+ }
+ }
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java
index a356acba2..5788ced1b 100644
--- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java
@@ -484,7 +484,7 @@ List> visit() {
}
}
- static class CommandParserException extends RuntimeException {
+ public static class CommandParserException extends RuntimeException {
public CommandParserException(String message) {
super(message);
@@ -499,7 +499,7 @@ public static CommandParserException of(String message) {
}
}
- static class MissingOptionException extends CommandParserException {
+ public static class MissingOptionException extends CommandParserException {
private CommandOption option;
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParserExceptionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParserExceptionResolver.java
new file mode 100644
index 000000000..b34237678
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParserExceptionResolver.java
@@ -0,0 +1,81 @@
+/*
+ * 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;
+
+import org.jline.utils.AttributedString;
+import org.jline.utils.AttributedStringBuilder;
+import org.jline.utils.AttributedStyle;
+
+import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException;
+import org.springframework.shell.command.CommandParser.MissingOptionException;
+import org.springframework.util.StringUtils;
+
+/**
+ * Handles {@link CommandParserExceptionsException}.
+ *
+ * @author Janne Valkealahti
+ */
+public class CommandParserExceptionResolver implements CommandExceptionResolver {
+
+ @Override
+ public CommandHandlingResult resolve(Exception ex) {
+ if (ex instanceof CommandParserExceptionsException cpee) {
+ AttributedStringBuilder builder = new AttributedStringBuilder();
+ cpee.getParserExceptions().stream().forEach(e -> {
+ if (e instanceof MissingOptionException moe) {
+ CommandOption option = moe.getOption();
+ if (option.getLongNames().length > 0) {
+ handleLong(builder, option);
+ }
+ else if (option.getShortNames().length > 0) {
+ handleShort(builder, option);
+ }
+ }
+ else {
+ builder.append(new AttributedString(e.getMessage(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
+ }
+ builder.append("\n");
+ });
+ String as = builder.toAttributedString().toAnsi();
+ return CommandHandlingResult.of(as);
+ }
+ return null;
+ }
+
+ private static void handleLong(AttributedStringBuilder builder, CommandOption option) {
+ StringBuilder buf = new StringBuilder();
+ buf.append("Missing mandatory option --");
+ buf.append(option.getLongNames()[0]);
+ if (StringUtils.hasText(option.getDescription())) {
+ buf.append(", ");
+ buf.append(option.getDescription());
+ }
+ buf.append(".");
+ builder.append(new AttributedString(buf.toString(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
+ }
+
+ private static void handleShort(AttributedStringBuilder builder, CommandOption option) {
+ StringBuilder buf = new StringBuilder();
+ buf.append("Missing mandatory option -");
+ buf.append(option.getShortNames()[0]);
+ if (StringUtils.hasText(option.getDescription())) {
+ buf.append(", ");
+ buf.append(option.getDescription());
+ }
+ buf.append(".");
+ builder.append(new AttributedString(buf.toString(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
+ }
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java
index 519108e40..2fc0f3b65 100644
--- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java
@@ -19,6 +19,7 @@
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -106,6 +107,13 @@ public interface CommandRegistration {
*/
CommandExitCode getExitCode();
+ /**
+ * Gets an exception resolvers.
+ *
+ * @return the exception resolvers
+ */
+ List getExceptionResolvers();
+
/**
* Gets a new instance of a {@link Builder}.
*
@@ -439,6 +447,27 @@ public interface ExitCodeSpec {
Builder and();
}
+ /**
+ * Spec defining an error handling.
+ */
+ public interface ErrorHandlingSpec {
+
+ /**
+ * Add {@link CommandExceptionResolver}.
+ *
+ * @param resolver the resolver
+ * @return a error handling for chaining
+ */
+ ErrorHandlingSpec resolver(CommandExceptionResolver resolver);
+
+ /**
+ * Return a builder for chaining.
+ *
+ * @return a builder for chaining
+ */
+ Builder and();
+ }
+
/**
* Builder interface for {@link CommandRegistration}.
*/
@@ -516,6 +545,13 @@ public interface Builder {
*/
ExitCodeSpec withExitCode();
+ /**
+ * Define an error handling what this command should use
+ *
+ * @return error handling spec for chaining
+ */
+ ErrorHandlingSpec withErrorHandling();
+
/**
* Builds a {@link CommandRegistration}.
*
@@ -804,6 +840,27 @@ public Builder and() {
}
}
+ static class DefaultErrorHandlingSpec implements ErrorHandlingSpec {
+
+ private BaseBuilder builder;
+ private final List resolvers = new ArrayList<>();
+
+ DefaultErrorHandlingSpec(BaseBuilder builder) {
+ this.builder = builder;
+ }
+
+ @Override
+ public ErrorHandlingSpec resolver(CommandExceptionResolver resolver) {
+ this.resolvers.add(resolver);
+ return this;
+ }
+
+ @Override
+ public Builder and() {
+ return builder;
+ }
+ }
+
static class DefaultCommandRegistration implements CommandRegistration {
private String command;
@@ -815,10 +872,12 @@ static class DefaultCommandRegistration implements CommandRegistration {
private DefaultTargetSpec targetSpec;
private List aliasSpecs;
private DefaultExitCodeSpec exitCodeSpec;
+ private DefaultErrorHandlingSpec errorHandlingSpec;
public DefaultCommandRegistration(String[] commands, InteractionMode interactionMode, String group,
String description, Supplier availability, List optionSpecs,
- DefaultTargetSpec targetSpec, List aliasSpecs, DefaultExitCodeSpec exitCodeSpec) {
+ DefaultTargetSpec targetSpec, List aliasSpecs, DefaultExitCodeSpec exitCodeSpec,
+ DefaultErrorHandlingSpec errorHandlingSpec) {
this.command = commandArrayToName(commands);
this.interactionMode = interactionMode;
this.group = group;
@@ -828,6 +887,7 @@ public DefaultCommandRegistration(String[] commands, InteractionMode interaction
this.targetSpec = targetSpec;
this.aliasSpecs = aliasSpecs;
this.exitCodeSpec = exitCodeSpec;
+ this.errorHandlingSpec = errorHandlingSpec;
}
@Override
@@ -897,6 +957,16 @@ public CommandExitCode getExitCode() {
}
}
+ @Override
+ public List getExceptionResolvers() {
+ if (this.errorHandlingSpec == null) {
+ return Collections.emptyList();
+ }
+ else {
+ return this.errorHandlingSpec.resolvers;
+ }
+ }
+
private static String commandArrayToName(String[] commands) {
return Arrays.asList(commands).stream()
.flatMap(c -> Stream.of(c.split(" ")))
@@ -921,6 +991,7 @@ static class BaseBuilder implements Builder {
private List aliasSpecs = new ArrayList<>();
private DefaultTargetSpec targetSpec;
private DefaultExitCodeSpec exitCodeSpec;
+ private DefaultErrorHandlingSpec errorHandlingSpec;
@Override
public Builder command(String... commands) {
@@ -986,13 +1057,20 @@ public ExitCodeSpec withExitCode() {
return spec;
}
+ @Override
+ public ErrorHandlingSpec withErrorHandling() {
+ DefaultErrorHandlingSpec spec = new DefaultErrorHandlingSpec(this);
+ this.errorHandlingSpec = spec;
+ return spec;
+ }
+
@Override
public CommandRegistration build() {
Assert.notNull(commands, "command cannot be empty");
Assert.notNull(targetSpec, "target cannot be empty");
Assert.state(!(targetSpec.bean != null && targetSpec.function != null), "only one target can exist");
return new DefaultCommandRegistration(commands, interactionMode, group, description, availability,
- optionSpecs, targetSpec, aliasSpecs, exitCodeSpec);
+ optionSpecs, targetSpec, aliasSpecs, exitCodeSpec, errorHandlingSpec);
}
}
}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/exit/ExitCodeExceptionProvider.java b/spring-shell-core/src/main/java/org/springframework/shell/exit/ExitCodeExceptionProvider.java
new file mode 100644
index 000000000..a198e89bb
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/exit/ExitCodeExceptionProvider.java
@@ -0,0 +1,27 @@
+/*
+ * 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.exit;
+
+import java.util.function.BiFunction;
+
+/**
+ * Interface to provide exception for an exit code. Typically providing
+ * exception implementing boot's {@code ExitCodeGenerator}.
+ *
+ * @author Janne Valkealahti
+ */
+public interface ExitCodeExceptionProvider extends BiFunction {
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/CommandParserExceptionsExceptionResultHandler.java b/spring-shell-core/src/main/java/org/springframework/shell/result/CommandParserExceptionsExceptionResultHandler.java
deleted file mode 100644
index 61d0cd906..000000000
--- a/spring-shell-core/src/main/java/org/springframework/shell/result/CommandParserExceptionsExceptionResultHandler.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package org.springframework.shell.result;
-
-import org.jline.terminal.Terminal;
-import org.jline.utils.AttributedString;
-import org.jline.utils.AttributedStringBuilder;
-import org.jline.utils.AttributedStyle;
-
-import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException;;
-
-/**
- * Displays command parsing errors on the terminal.
- *
- * @author Janne Valkealahti
- */
-public class CommandParserExceptionsExceptionResultHandler extends TerminalAwareResultHandler {
-
- public CommandParserExceptionsExceptionResultHandler(Terminal terminal) {
- super(terminal);
- }
-
- @Override
- protected void doHandleResult(CommandParserExceptionsException result) {
- AttributedStringBuilder builder = new AttributedStringBuilder();
- result.getParserExceptions().stream().forEach(e -> {
- builder.append(new AttributedString(e.getMessage(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
- builder.append("\n");
- });
- terminal.writer().append(builder.toAnsi());
- }
-}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java b/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java
index 2d1cd9f5a..66a445550 100644
--- a/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java
+++ b/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java
@@ -23,6 +23,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.shell.TerminalSizeAware;
import org.springframework.shell.command.CommandCatalog;
+import org.springframework.shell.command.CommandParserExceptionResolver;
import org.springframework.shell.context.ShellContext;
import org.springframework.shell.jline.InteractiveShellRunner;
@@ -57,8 +58,8 @@ public ParameterValidationExceptionResultHandler parameterValidationExceptionRes
}
@Bean
- public CommandParserExceptionsExceptionResultHandler commandParserExceptionsExceptionResultHandler(Terminal terminal) {
- return new CommandParserExceptionsExceptionResultHandler(terminal);
+ public CommandParserExceptionResolver commandParserExceptionResolver() {
+ return new CommandParserExceptionResolver();
}
@Bean
diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandHandlingResultTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandHandlingResultTests.java
new file mode 100644
index 000000000..55fcc48e6
--- /dev/null
+++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandHandlingResultTests.java
@@ -0,0 +1,79 @@
+/*
+ * 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;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class CommandHandlingResultTests {
+
+ @Test
+ void hasMessageFromOf() {
+ CommandHandlingResult result = CommandHandlingResult.of("fake");
+ assertThat(result).isNotNull();
+ assertThat(result.message()).isEqualTo("fake");
+ assertThat(result.isPresent()).isTrue();
+ assertThat(result.isEmpty()).isFalse();
+ }
+
+ @Test
+ void hasMessageAndCodeFromOf() {
+ CommandHandlingResult result = CommandHandlingResult.of("fake", 1);
+ assertThat(result).isNotNull();
+ assertThat(result.message()).isEqualTo("fake");
+ assertThat(result.exitCode()).isEqualTo(1);
+ assertThat(result.isPresent()).isTrue();
+ assertThat(result.isEmpty()).isFalse();
+ }
+
+ @Test
+ void hasNotMessageFromOf() {
+ CommandHandlingResult result = CommandHandlingResult.of(null);
+ assertThat(result).isNotNull();
+ assertThat(result.message()).isNull();
+ assertThat(result.isPresent()).isFalse();
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ @Test
+ void hasNotMessageAndCodeFromOf() {
+ CommandHandlingResult result = CommandHandlingResult.of(null);
+ assertThat(result).isNotNull();
+ assertThat(result.message()).isNull();
+ assertThat(result.exitCode()).isNull();
+ assertThat(result.isPresent()).isFalse();
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ @Test
+ void hasNotMessageFromEmpty() {
+ CommandHandlingResult result = CommandHandlingResult.empty();
+ assertThat(result).isNotNull();
+ assertThat(result.message()).isNull();
+ assertThat(result.exitCode()).isNull();
+ assertThat(result.isPresent()).isFalse();
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ @Test
+ void isPresentJustWithCode() {
+ CommandHandlingResult result = CommandHandlingResult.of(null, 1);
+ assertThat(result).isNotNull();
+ assertThat(result.isPresent()).isTrue();
+ assertThat(result.isEmpty()).isFalse();
+ }
+}
diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserExceptionResolverTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserExceptionResolverTests.java
new file mode 100644
index 000000000..db47597ea
--- /dev/null
+++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserExceptionResolverTests.java
@@ -0,0 +1,97 @@
+/*
+ * 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;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException;
+import org.springframework.shell.command.CommandParser.CommandParserException;
+import org.springframework.shell.command.CommandParser.MissingOptionException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CommandParserExceptionResolverTests {
+
+ private final CommandParserExceptionResolver resolver = new CommandParserExceptionResolver();
+
+ @Test
+ void resolvesMissingLongOption() {
+ CommandRegistration registration = CommandRegistration.builder()
+ .command("required-value")
+ .withOption()
+ .longNames("arg1")
+ .description("Desc arg1")
+ .required()
+ .and()
+ .withTarget()
+ .consumer(ctx -> {})
+ .and()
+ .build();
+
+ CommandHandlingResult resolve = resolver.resolve(of(registration.getOptions().get(0)));
+ assertThat(resolve).isNotNull();
+ assertThat(resolve.message()).contains("--arg1", "Desc arg1");
+ }
+
+ @Test
+ void resolvesMissingLongOptionWhenAlsoShort() {
+ CommandRegistration registration = CommandRegistration.builder()
+ .command("required-value")
+ .withOption()
+ .longNames("arg1")
+ .shortNames('x')
+ .description("Desc arg1")
+ .required()
+ .and()
+ .withTarget()
+ .consumer(ctx -> {})
+ .and()
+ .build();
+
+ CommandHandlingResult resolve = resolver.resolve(of(registration.getOptions().get(0)));
+ assertThat(resolve).isNotNull();
+ assertThat(resolve.message()).contains("--arg1", "Desc arg1");
+ assertThat(resolve.message()).doesNotContain("-x", "Desc x");
+ }
+
+ @Test
+ void resolvesMissingShortOption() {
+ CommandRegistration registration = CommandRegistration.builder()
+ .command("required-value")
+ .withOption()
+ .shortNames('x')
+ .description("Desc x")
+ .required()
+ .and()
+ .withTarget()
+ .consumer(ctx -> {})
+ .and()
+ .build();
+
+ CommandHandlingResult resolve = resolver.resolve(of(registration.getOptions().get(0)));
+ assertThat(resolve).isNotNull();
+ assertThat(resolve.message()).contains("-x", "Desc x");
+ }
+
+ static CommandParserExceptionsException of(CommandOption option) {
+ MissingOptionException moe = new MissingOptionException("msg", option);
+ List parserExceptions = Arrays.asList(moe);
+ return new CommandParserExceptionsException("msg", parserExceptions);
+ }
+}
diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java
index e025b67f2..3c4f60e2b 100644
--- a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java
+++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java
@@ -455,4 +455,34 @@ public void testOptionWithCompletion() {
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getCompletion()).isNotNull();
}
+
+ @Test
+ public void testErrorHandling() {
+ CommandExceptionResolver er1 = new CommandExceptionResolver() {
+ @Override
+ public CommandHandlingResult resolve(Exception e) {
+ return CommandHandlingResult.empty();
+ }
+ };
+ CommandRegistration registration;
+ registration = CommandRegistration.builder()
+ .command("command1")
+ .withErrorHandling()
+ .resolver(er1)
+ .and()
+ .withTarget()
+ .function(function1)
+ .and()
+ .build();
+ assertThat(registration.getExceptionResolvers()).hasSize(1);
+ assertThat(registration.getExceptionResolvers().get(0)).isSameAs(er1);
+
+ registration = CommandRegistration.builder()
+ .command("command1")
+ .withTarget()
+ .function(function1)
+ .and()
+ .build();
+ assertThat(registration.getExceptionResolvers()).hasSize(0);
+ }
}
diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-commands-exitcode.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling.adoc
similarity index 54%
rename from spring-shell-docs/src/main/asciidoc/using-shell-commands-exitcode.adoc
rename to spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling.adoc
index 9912ec83f..6a79d5c90 100644
--- a/spring-shell-docs/src/main/asciidoc/using-shell-commands-exitcode.adoc
+++ b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exceptionhandling.adoc
@@ -1,5 +1,5 @@
[[dynamic-command-exitcode]]
-=== Exit Code
+=== Exception Handling
ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs]
Many command line applications when applicable return an _exit code_ which running environment
@@ -7,6 +7,44 @@ can use to differentiate if command has been executed successfully or not. In a
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`.
+==== Exception Resolving
+
+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`
@@ -37,3 +75,4 @@ 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.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-commands.adoc
index 3c3683ed4..6febf45b3 100644
--- a/spring-shell-docs/src/main/asciidoc/using-shell-commands.adoc
+++ b/spring-shell-docs/src/main/asciidoc/using-shell-commands.adoc
@@ -19,4 +19,4 @@ include::using-shell-commands-organize.adoc[]
include::using-shell-commands-availability.adoc[]
-include::using-shell-commands-exitcode.adoc[]
+include::using-shell-commands-exceptionhandling.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
new file mode 100644
index 000000000..c934b83f0
--- /dev/null
+++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/ErrorHandlingSnippets.java
@@ -0,0 +1,51 @@
+/*
+ * 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.docs;
+
+import org.springframework.shell.command.CommandRegistration;
+import org.springframework.shell.command.CommandExceptionResolver;
+import org.springframework.shell.command.CommandHandlingResult;
+
+class ErrorHandlingSnippets {
+
+ // tag::my-exception-class[]
+ static class CustomException extends RuntimeException {}
+ // end::my-exception-class[]
+
+ // tag::my-exception-resolver-class[]
+ static class CustomExceptionResolver implements CommandExceptionResolver {
+
+ @Override
+ public CommandHandlingResult resolve(Exception e) {
+ if (e instanceof CustomException) {
+ return CommandHandlingResult.of("Hi, handled exception\n", 42);
+ }
+ return null;
+ }
+ }
+ // end::my-exception-resolver-class[]
+
+ void dump1() {
+ // tag::example1[]
+ CommandRegistration.builder()
+ .withErrorHandling()
+ .resolver(new CustomExceptionResolver())
+ .and()
+ .build();
+ // end::example1[]
+ }
+
+}
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
new file mode 100644
index 000000000..322930fc0
--- /dev/null
+++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ErrorHandlingCommands.java
@@ -0,0 +1,91 @@
+/*
+ * 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.samples.e2e;
+
+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.standard.ShellComponent;
+
+/**
+ * Commands used for e2e test.
+ *
+ * @author Janne Valkealahti
+ */
+@ShellComponent
+public class ErrorHandlingCommands extends BaseE2ECommands {
+
+ @Bean
+ public CommandRegistration testErrorHandlingRegistration() {
+ return CommandRegistration.builder()
+ .command(REG, "error-handling")
+ .group(GROUP)
+ .withOption()
+ .longNames("arg1")
+ .required()
+ .and()
+ .withErrorHandling()
+ .resolver(new CustomExceptionResolver())
+ .and()
+ .withTarget()
+ .function(ctx -> {
+ String arg1 = ctx.getOptionValue("arg1");
+ if ("throw1".equals(arg1)) {
+ throw new CustomException1();
+ }
+ if ("throw2".equals(arg1)) {
+ throw new CustomException2(11);
+ }
+ if ("throw3".equals(arg1)) {
+ throw new RuntimeException();
+ }
+ return "Hello " + arg1;
+ })
+ .and()
+ .build();
+ }
+
+ private static class CustomException1 extends RuntimeException {
+ }
+
+ private static class CustomException2 extends RuntimeException implements ExitCodeGenerator {
+
+ private int code;
+
+ CustomException2(int code) {
+ this.code = code;
+ }
+
+ @Override
+ public int getExitCode() {
+ return code;
+ }
+ }
+
+
+ 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 null;
+ }
+ }
+}
diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/RequiredValueCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/RequiredValueCommands.java
index 0d7c6095d..eeae60a41 100644
--- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/RequiredValueCommands.java
+++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/RequiredValueCommands.java
@@ -31,7 +31,7 @@ public class RequiredValueCommands extends BaseE2ECommands {
@ShellMethod(key = LEGACY_ANNO + "required-value", group = GROUP)
public String testRequiredValueAnnotation(
- @ShellOption String arg1
+ @ShellOption(help = "Desc arg1") String arg1
) {
return "Hello " + arg1;
}
@@ -43,6 +43,7 @@ public CommandRegistration testRequiredValueRegistration() {
.group(GROUP)
.withOption()
.longNames("arg1")
+ .description("Desc arg1")
.required()
.and()
.withTarget()
diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ValidatedValueCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ValidatedValueCommands.java
new file mode 100644
index 000000000..6e2fbfa2f
--- /dev/null
+++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/ValidatedValueCommands.java
@@ -0,0 +1,66 @@
+/*
+ * 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.samples.e2e;
+
+import jakarta.validation.constraints.Min;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.shell.command.CommandRegistration;
+import org.springframework.shell.standard.ShellComponent;
+import org.springframework.shell.standard.ShellMethod;
+import org.springframework.shell.standard.ShellOption;
+
+/**
+ * Commands used for e2e test.
+ *
+ * @author Janne Valkealahti
+ */
+@ShellComponent
+public class ValidatedValueCommands extends BaseE2ECommands {
+
+ @ShellMethod(key = LEGACY_ANNO + "validated-value", group = GROUP)
+ public String testValidatedValueAnnotation(
+ @ShellOption @Min(value = 1) Integer arg1,
+ @ShellOption @Min(value = 1) Integer arg2
+ ) {
+ return "Hello " + arg1;
+ }
+
+ @Bean
+ public CommandRegistration testValidatedValueRegistration() {
+ return CommandRegistration.builder()
+ .command(REG, "validated-value")
+ .group(GROUP)
+ .withOption()
+ .longNames("arg1")
+ .type(Integer.class)
+ .required()
+ .and()
+ .withOption()
+ .longNames("arg2")
+ .type(Integer.class)
+ .required()
+ .and()
+ .withTarget()
+ .function(ctx -> {
+ Integer arg1 = ctx.getOptionValue("arg1");
+ return "Hello " + arg1;
+ })
+ .and()
+ .build();
+ }
+
+}