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
This commit is contained in:
Janne Valkealahti
2022-10-14 10:46:27 +01:00
parent 3fe26025cd
commit 4c48017a97
23 changed files with 999 additions and 102 deletions

32
.vscode/launch.json vendored
View File

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

View File

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

View File

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

View File

@@ -20,11 +20,13 @@ import java.util.List;
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 class ExitCodeAutoConfiguration {
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 class ExitCodeAutoConfiguration {
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;
}
}
}

View File

@@ -26,7 +26,6 @@ import java.util.stream.Stream;
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.core.convert.support.DefaultConversionService;
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<CommandExceptionResolver> exceptionResolvers = new ArrayList<>();
public Shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal,
ShellContext shellContext, ExitCodeMappings exitCodeMappings) {
@@ -111,6 +116,18 @@ public class Shell {
this.validator = validatorFactory.getValidator();
}
@Autowired(required = false)
public void setExceptionResolvers(List<CommandExceptionResolver> 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 class Shell {
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,73 +189,123 @@ public class Shell {
* result
* </p>
*/
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<String> words = input.words();
String line = words.stream().collect(Collectors.joining(" ")).trim();
String command = findLongestCommand(line);
List<String> words = input.words();
log.debug("Evaluate input with line=[{}], command=[{}]", line, command);
if (command != null) {
Optional<CommandRegistration> 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;
}
}
return false;
})
.findFirst();
if (commandRegistration.isPresent()) {
if (this.exitCodeMappings != null) {
List<Function<Throwable, Integer>> mappingFunctions = commandRegistration.get().getExitCode()
.getMappingFunctions();
this.exitCodeMappings.reset(mappingFunctions);
}
List<String> wordsForArgs = wordsForArguments(command, 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;
}
finally {
Signals.unregister("INT", sh);
}
}
else {
return new CommandNotFound(words);
}
}
else {
if (command == null) {
return new CommandNotFound(words);
}
log.debug("Evaluate input with line=[{}], command=[{}]", line, command);
Optional<CommandRegistration> 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;
}
}
return false;
})
.findFirst();
if (commandRegistration.isEmpty()) {
return new CommandNotFound(words);
}
if (this.exitCodeMappings != null) {
List<Function<Throwable, Integer>> mappingFunctions = commandRegistration.get().getExitCode()
.getMappingFunctions();
this.exitCodeMappings.reset(mappingFunctions);
}
List<String> 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<CommandExceptionResolver> 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;
}
} catch (Exception e1) {
e = e1;
}
}
if (e != null) {
evaluate = e;
}
return evaluate;
}
private CommandHandlingResult processException(List<CommandExceptionResolver> 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 r;
}
}
throw e;
}
/**
* Return true if the parsed input ends up being empty (<em>e.g.</em> hitting ENTER on an

View File

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

View File

@@ -201,7 +201,7 @@ public interface CommandExecution {
}
}
static class CommandParserExceptionsException extends RuntimeException {
public static class CommandParserExceptionsException extends RuntimeException {
private final List<CommandParserException> parserExceptions;

View File

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

View File

@@ -484,7 +484,7 @@ public interface CommandParser {
}
}
static class CommandParserException extends RuntimeException {
public static class CommandParserException extends RuntimeException {
public CommandParserException(String message) {
super(message);
@@ -499,7 +499,7 @@ public interface CommandParser {
}
}
static class MissingOptionException extends CommandParserException {
public static class MissingOptionException extends CommandParserException {
private CommandOption option;

View File

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

View File

@@ -19,6 +19,7 @@ import java.lang.reflect.Method;
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<CommandExceptionResolver> getExceptionResolvers();
/**
* Gets a new instance of a {@link Builder}.
*
@@ -439,6 +447,27 @@ public interface CommandRegistration {
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 CommandRegistration {
*/
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 interface CommandRegistration {
}
}
static class DefaultErrorHandlingSpec implements ErrorHandlingSpec {
private BaseBuilder builder;
private final List<CommandExceptionResolver> 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 @@ public interface CommandRegistration {
private DefaultTargetSpec targetSpec;
private List<DefaultAliasSpec> aliasSpecs;
private DefaultExitCodeSpec exitCodeSpec;
private DefaultErrorHandlingSpec errorHandlingSpec;
public DefaultCommandRegistration(String[] commands, InteractionMode interactionMode, String group,
String description, Supplier<Availability> availability, List<DefaultOptionSpec> optionSpecs,
DefaultTargetSpec targetSpec, List<DefaultAliasSpec> aliasSpecs, DefaultExitCodeSpec exitCodeSpec) {
DefaultTargetSpec targetSpec, List<DefaultAliasSpec> aliasSpecs, DefaultExitCodeSpec exitCodeSpec,
DefaultErrorHandlingSpec errorHandlingSpec) {
this.command = commandArrayToName(commands);
this.interactionMode = interactionMode;
this.group = group;
@@ -828,6 +887,7 @@ public interface CommandRegistration {
this.targetSpec = targetSpec;
this.aliasSpecs = aliasSpecs;
this.exitCodeSpec = exitCodeSpec;
this.errorHandlingSpec = errorHandlingSpec;
}
@Override
@@ -897,6 +957,16 @@ public interface CommandRegistration {
}
}
@Override
public List<CommandExceptionResolver> 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 @@ public interface CommandRegistration {
private List<DefaultAliasSpec> aliasSpecs = new ArrayList<>();
private DefaultTargetSpec targetSpec;
private DefaultExitCodeSpec exitCodeSpec;
private DefaultErrorHandlingSpec errorHandlingSpec;
@Override
public Builder command(String... commands) {
@@ -986,13 +1057,20 @@ public interface CommandRegistration {
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);
}
}
}

View File

@@ -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<Exception, Integer, Exception> {
}

View File

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

View File

@@ -23,6 +23,7 @@ import org.springframework.context.annotation.Bean;
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 class ResultHandlerConfig {
}
@Bean
public CommandParserExceptionsExceptionResultHandler commandParserExceptionsExceptionResultHandler(Terminal terminal) {
return new CommandParserExceptionsExceptionResultHandler(terminal);
public CommandParserExceptionResolver commandParserExceptionResolver() {
return new CommandParserExceptionResolver();
}
@Bean

View File

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

View File

@@ -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<CommandParserException> parserExceptions = Arrays.asList(moe);
return new CommandParserExceptionsException("msg", parserExceptions);
}
}

View File

@@ -455,4 +455,34 @@ public class CommandRegistrationTests extends AbstractCommandTests {
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 class RequiredValueCommands extends BaseE2ECommands {
.group(GROUP)
.withOption()
.longNames("arg1")
.description("Desc arg1")
.required()
.and()
.withTarget()

View File

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