Skip to content

Commit 4c48017

Browse files
committed
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
1 parent 3fe2602 commit 4c48017

File tree

23 files changed

+989
-92
lines changed

23 files changed

+989
-92
lines changed

.vscode/launch.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,38 @@
3232
"projectName": "spring-shell-samples",
3333
"args": "fail --elementType TYPE"
3434
},
35+
{
36+
"type": "java",
37+
"name": "e2e reg error-handling",
38+
"request": "launch",
39+
"mainClass": "org.springframework.shell.samples.SpringShellSample",
40+
"projectName": "spring-shell-samples",
41+
"args": "e2e reg error-handling"
42+
},
43+
{
44+
"type": "java",
45+
"name": "e2e reg error-handling arg1 throw1",
46+
"request": "launch",
47+
"mainClass": "org.springframework.shell.samples.SpringShellSample",
48+
"projectName": "spring-shell-samples",
49+
"args": "e2e reg error-handling --arg1 throw1"
50+
},
51+
{
52+
"type": "java",
53+
"name": "e2e reg error-handling arg1 throw2",
54+
"request": "launch",
55+
"mainClass": "org.springframework.shell.samples.SpringShellSample",
56+
"projectName": "spring-shell-samples",
57+
"args": "e2e reg error-handling --arg1 throw2"
58+
},
59+
{
60+
"type": "java",
61+
"name": "e2e reg error-handling arg1 throw3",
62+
"request": "launch",
63+
"mainClass": "org.springframework.shell.samples.SpringShellSample",
64+
"projectName": "spring-shell-samples",
65+
"args": "e2e reg error-handling --arg1 throw3"
66+
},
3567
{
3668
"type": "java",
3769
"name": "e2e exit-code noarg",

e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('e2e commands exit-code', () => {
2323
cli.run();
2424
await waitForExpect(async () => {
2525
const screen = cli.screen();
26-
expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing option')]));
26+
expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing mandatory option')]));
2727
});
2828
await expect(cli.exitCode()).resolves.toBe(2);
2929
};

e2e/spring-shell-e2e-tests/test/sample-e2e-required-value.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('e2e commands required-value', () => {
2323
cli.run();
2424
await waitForExpect(async () => {
2525
const screen = cli.screen();
26-
expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing option')]));
26+
expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing mandatory option')]));
2727
});
2828
await expect(cli.exitCode()).resolves.toBe(2);
2929
};
@@ -34,7 +34,7 @@ describe('e2e commands required-value', () => {
3434
cli.run();
3535
await waitForExpect(async () => {
3636
const screen = cli.screen();
37-
expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing option')]));
37+
expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing mandatory option')]));
3838
});
3939
await expect(cli.exitCode()).resolves.toBe(2);
4040
};

spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
import java.util.function.Function;
2121

2222
import org.springframework.boot.ExitCodeExceptionMapper;
23+
import org.springframework.boot.ExitCodeGenerator;
2324
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
2425
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
2526
import org.springframework.context.annotation.Bean;
2627
import org.springframework.context.annotation.Configuration;
2728
import org.springframework.shell.command.CommandExecution;
29+
import org.springframework.shell.exit.ExitCodeExceptionProvider;
2830
import org.springframework.shell.exit.ExitCodeMappings;
2931

3032
/**
@@ -47,6 +49,12 @@ public ShellExitCodeMappingsExceptionMapper shellExitCodeMappingsExceptionMapper
4749
return new ShellExitCodeMappingsExceptionMapper();
4850
}
4951

52+
@Bean
53+
@ConditionalOnMissingBean
54+
public ExitCodeExceptionProvider exitCodeExceptionProvider() {
55+
return (exception, code) -> new ShellExitCodeException(exception, code);
56+
}
57+
5058
static class ShellExitCodeExceptionMapper implements ExitCodeExceptionMapper {
5159

5260
@Override
@@ -85,4 +93,19 @@ public int getExitCode(Throwable exception) {
8593
return exitCode;
8694
}
8795
}
96+
97+
static class ShellExitCodeException extends RuntimeException implements ExitCodeGenerator {
98+
99+
private int code;
100+
101+
ShellExitCodeException(Throwable throwable, int code) {
102+
super(throwable);
103+
this.code = code;
104+
}
105+
106+
@Override
107+
public int getExitCode() {
108+
return code;
109+
}
110+
}
88111
}

spring-shell-core/src/main/java/org/springframework/shell/Shell.java

Lines changed: 123 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626

2727
import jakarta.validation.Validator;
2828
import jakarta.validation.ValidatorFactory;
29-
3029
import org.jline.terminal.Terminal;
3130
import org.jline.utils.Signals;
3231
import org.slf4j.Logger;
@@ -39,13 +38,16 @@
3938
import org.springframework.shell.command.CommandAlias;
4039
import org.springframework.shell.command.CommandCatalog;
4140
import org.springframework.shell.command.CommandExecution;
42-
import org.springframework.shell.command.CommandOption;
4341
import org.springframework.shell.command.CommandExecution.CommandExecutionException;
4442
import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers;
43+
import org.springframework.shell.command.CommandOption;
4544
import org.springframework.shell.command.CommandRegistration;
45+
import org.springframework.shell.command.CommandExceptionResolver;
46+
import org.springframework.shell.command.CommandHandlingResult;
4647
import org.springframework.shell.completion.CompletionResolver;
4748
import org.springframework.shell.context.InteractionMode;
4849
import org.springframework.shell.context.ShellContext;
50+
import org.springframework.shell.exit.ExitCodeExceptionProvider;
4951
import org.springframework.shell.exit.ExitCodeMappings;
5052
import org.springframework.util.StringUtils;
5153

@@ -73,6 +75,8 @@ public class Shell {
7375
private ConversionService conversionService = new DefaultConversionService();
7476
private final ShellContext shellContext;
7577
private final ExitCodeMappings exitCodeMappings;
78+
private Exception handlingResultNonInt = null;
79+
private CommandHandlingResult processExceptionNonInt = null;
7680

7781
/**
7882
* Marker object to distinguish unresolved arguments from {@code null}, which is a valid
@@ -81,6 +85,7 @@ public class Shell {
8185
protected static final Object UNRESOLVED = new Object();
8286

8387
private Validator validator = Utils.defaultValidator();
88+
private List<CommandExceptionResolver> exceptionResolvers = new ArrayList<>();
8489

8590
public Shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal,
8691
ShellContext shellContext, ExitCodeMappings exitCodeMappings) {
@@ -111,6 +116,18 @@ public void setValidatorFactory(ValidatorFactory validatorFactory) {
111116
this.validator = validatorFactory.getValidator();
112117
}
113118

119+
@Autowired(required = false)
120+
public void setExceptionResolvers(List<CommandExceptionResolver> exceptionResolvers) {
121+
this.exceptionResolvers = exceptionResolvers;
122+
}
123+
124+
private ExitCodeExceptionProvider exitCodeExceptionProvider;
125+
126+
@Autowired(required = false)
127+
public void setExitCodeExceptionProvider(ExitCodeExceptionProvider exitCodeExceptionProvider) {
128+
this.exitCodeExceptionProvider = exitCodeExceptionProvider;
129+
}
130+
114131
/**
115132
* The main program loop: acquire input, try to match it to a command and evaluate. Repeat
116133
* 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 {
152169
else if (result instanceof Exception) {
153170
throw (Exception) result;
154171
}
172+
if (handlingResultNonInt instanceof CommandExecution.CommandParserExceptionsException) {
173+
throw (CommandExecution.CommandParserExceptionsException) handlingResultNonInt;
174+
}
175+
else if (processExceptionNonInt != null && processExceptionNonInt.exitCode() != null
176+
&& exitCodeExceptionProvider != null) {
177+
throw exitCodeExceptionProvider.apply(null, processExceptionNonInt.exitCode());
178+
}
155179
}
156180
}
157181
}
@@ -165,74 +189,124 @@ else if (result instanceof Exception) {
165189
* result
166190
* </p>
167191
*/
168-
public Object evaluate(Input input) {
192+
private Object evaluate(Input input) {
169193
if (noInput(input)) {
170194
return NO_INPUT;
171195
}
172196

173-
String line = input.words().stream().collect(Collectors.joining(" ")).trim();
197+
List<String> words = input.words();
198+
String line = words.stream().collect(Collectors.joining(" ")).trim();
174199
String command = findLongestCommand(line);
175200

176-
List<String> words = input.words();
201+
if (command == null) {
202+
return new CommandNotFound(words);
203+
}
204+
177205
log.debug("Evaluate input with line=[{}], command=[{}]", line, command);
178-
if (command != null) {
179206

180-
Optional<CommandRegistration> commandRegistration = commandRegistry.getRegistrations().values().stream()
181-
.filter(r -> {
182-
if (r.getCommand().equals(command)) {
207+
Optional<CommandRegistration> commandRegistration = commandRegistry.getRegistrations().values().stream()
208+
.filter(r -> {
209+
if (r.getCommand().equals(command)) {
210+
return true;
211+
}
212+
for (CommandAlias a : r.getAliases()) {
213+
if (a.getCommand().equals(command)) {
183214
return true;
184215
}
185-
for (CommandAlias a : r.getAliases()) {
186-
if (a.getCommand().equals(command)) {
187-
return true;
188-
}
189-
}
190-
return false;
191-
})
192-
.findFirst();
193-
194-
if (commandRegistration.isPresent()) {
195-
if (this.exitCodeMappings != null) {
196-
List<Function<Throwable, Integer>> mappingFunctions = commandRegistration.get().getExitCode()
197-
.getMappingFunctions();
198-
this.exitCodeMappings.reset(mappingFunctions);
199216
}
217+
return false;
218+
})
219+
.findFirst();
200220

201-
List<String> wordsForArgs = wordsForArguments(command, words);
221+
if (commandRegistration.isEmpty()) {
222+
return new CommandNotFound(words);
223+
}
202224

203-
Thread commandThread = Thread.currentThread();
204-
Object sh = Signals.register("INT", () -> commandThread.interrupt());
205-
try {
206-
CommandExecution execution = CommandExecution
207-
.of(argumentResolvers != null ? argumentResolvers.getResolvers() : null, validator, terminal, conversionService);
208-
return execution.evaluate(commandRegistration.get(), wordsForArgs.toArray(new String[0]));
209-
}
210-
catch (UndeclaredThrowableException e) {
211-
if (e.getCause() instanceof InterruptedException || e.getCause() instanceof ClosedByInterruptException) {
212-
Thread.interrupted(); // to reset interrupted flag
213-
}
214-
return e.getCause();
215-
}
216-
catch (CommandExecutionException e) {
217-
return e.getCause();
218-
}
219-
catch (Exception e) {
220-
return e;
225+
if (this.exitCodeMappings != null) {
226+
List<Function<Throwable, Integer>> mappingFunctions = commandRegistration.get().getExitCode()
227+
.getMappingFunctions();
228+
this.exitCodeMappings.reset(mappingFunctions);
229+
}
230+
231+
List<String> wordsForArgs = wordsForArguments(command, words);
232+
233+
Thread commandThread = Thread.currentThread();
234+
Object sh = Signals.register("INT", () -> commandThread.interrupt());
235+
236+
CommandExecution execution = CommandExecution.of(
237+
argumentResolvers != null ? argumentResolvers.getResolvers() : null, validator, terminal,
238+
conversionService);
239+
240+
List<CommandExceptionResolver> commandExceptionResolvers = commandRegistration.get().getExceptionResolvers();
241+
242+
Object evaluate = null;
243+
Exception e = null;
244+
try {
245+
evaluate = execution.evaluate(commandRegistration.get(), wordsForArgs.toArray(new String[0]));
246+
}
247+
catch (UndeclaredThrowableException ute) {
248+
if (ute.getCause() instanceof InterruptedException || ute.getCause() instanceof ClosedByInterruptException) {
249+
Thread.interrupted(); // to reset interrupted flag
250+
}
251+
return ute.getCause();
252+
}
253+
catch (CommandExecutionException e1) {
254+
return e1.getCause();
255+
}
256+
catch (Exception e2) {
257+
e = e2;
258+
}
259+
finally {
260+
Signals.unregister("INT", sh);
261+
}
262+
if (e != null) {
263+
try {
264+
CommandHandlingResult processException = processException(commandExceptionResolvers, e);
265+
processExceptionNonInt = processException;
266+
if (processException != null) {
267+
handlingResultNonInt = e;
268+
this.terminal.writer().append(processException.message());
269+
this.terminal.writer().flush();
270+
return null;
221271
}
222-
finally {
223-
Signals.unregister("INT", sh);
272+
} catch (Exception e1) {
273+
e = e1;
274+
}
275+
}
276+
if (e != null) {
277+
evaluate = e;
278+
}
279+
return evaluate;
280+
}
281+
282+
private CommandHandlingResult processException(List<CommandExceptionResolver> commandExceptionResolvers, Exception e)
283+
throws Exception {
284+
CommandHandlingResult r = null;
285+
for (CommandExceptionResolver resolver : commandExceptionResolvers) {
286+
r = resolver.resolve(e);
287+
if (r != null) {
288+
break;
289+
}
290+
}
291+
if (r == null) {
292+
for (CommandExceptionResolver resolver : exceptionResolvers) {
293+
r = resolver.resolve(e);
294+
if (r != null) {
295+
break;
224296
}
225297
}
298+
}
299+
if (r != null) {
300+
if (r.isEmpty()) {
301+
return null;
302+
}
226303
else {
227-
return new CommandNotFound(words);
304+
return r;
228305
}
229306
}
230-
else {
231-
return new CommandNotFound(words);
232-
}
307+
throw e;
233308
}
234309

235-
236310
/**
237311
* Return true if the parsed input ends up being empty (<em>e.g.</em> hitting ENTER on an
238312
* empty line or blank space).
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.shell.command;
17+
18+
/**
19+
* Interface to be implemented by objects that can resolve exceptions thrown
20+
* during command processing, in the typical case error response. Implementors
21+
* are typically registered as beans in the application context or directly
22+
* with command.
23+
*
24+
* @author Janne Valkealahti
25+
*/
26+
public interface CommandExceptionResolver {
27+
28+
/**
29+
* Try to resolve the given exception that got thrown during command processing.
30+
*
31+
* @param ex the exception
32+
* @return a corresponding {@code HandlingResult} framework to handle, or
33+
* {@code null} for default processing in the resolution chain
34+
*/
35+
CommandHandlingResult resolve(Exception ex);
36+
}

0 commit comments

Comments
 (0)