Skip to content

Implement more flexible error handling #553

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand All @@ -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);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}
}
172 changes: 123 additions & 49 deletions spring-shell-core/src/main/java/org/springframework/shell/Shell.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -111,6 +116,18 @@ public void setValidatorFactory(ValidatorFactory validatorFactory) {
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.
Expand Down Expand Up @@ -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());
}
}
}
}
Expand All @@ -165,74 +189,124 @@ else if (result instanceof Exception) {
* 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();
if (command == null) {
return new CommandNotFound(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)) {
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;
}
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);
}
return false;
})
.findFirst();

List<String> 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<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;
}
finally {
Signals.unregister("INT", sh);
} 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 new CommandNotFound(words);
return r;
}
}
else {
return new CommandNotFound(words);
}
throw e;
}


/**
* Return true if the parsed input ends up being empty (<em>e.g.</em> hitting ENTER on an
* empty line or blank space).
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Loading