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(); + } + +}