Skip to content

Commit 7b547e5

Browse files
committed
Add dynamic command registration
- Extend CommandRegistry for add/remove methods. - For rest of shell classes move to use registry directly instead of caching commands as registry is not immutable anymore. - Add new sample - Fixes #379
1 parent 8920db6 commit 7b547e5

File tree

7 files changed

+155
-83
lines changed

7 files changed

+155
-83
lines changed

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2015 the original author or authors.
2+
* Copyright 2015-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -13,7 +13,6 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
1716
package org.springframework.shell;
1817

1918
import java.util.Map;
@@ -23,13 +22,27 @@
2322
* discover available commands.
2423
*
2524
* @author Eric Bottard
25+
* @author Janne Valkealahti
2626
*/
2727
public interface CommandRegistry {
2828

29-
3029
/**
3130
* Return the mapping from command trigger keywords to implementation.
3231
*/
33-
public Map<String, MethodTarget> listCommands();
32+
Map<String, MethodTarget> listCommands();
3433

34+
/**
35+
* Register a new command.
36+
*
37+
* @param name the command name
38+
* @param target the method target
39+
*/
40+
void addCommand(String name, MethodTarget target);
41+
42+
/**
43+
* Deregister a command.
44+
*
45+
* @param name the command name
46+
*/
47+
void removeCommand(String name);
3548
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ else if (mim == InteractionMode.NONINTERACTIVE) {
5858
.collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
5959
}
6060

61+
@Override
62+
public void addCommand(String name, MethodTarget target) {
63+
commands.put(name, target);
64+
}
65+
66+
@Override
67+
public void removeCommand(String name) {
68+
commands.remove(name);
69+
}
70+
6171
public void register(String name, MethodTarget target) {
6272
MethodTarget previous = commands.get(name);
6373
if (previous != null) {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ public MethodTarget(Method method, Object bean, Help help, Supplier<Availability
6868
this.interactionMode = interactionMode;
6969
}
7070

71+
/**
72+
* Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception
73+
* in case of overloaded method.
74+
*/
75+
public static MethodTarget of(String name, Object bean, String description, String group) {
76+
return of(name, bean, new Help(description, group));
77+
}
78+
7179
/**
7280
* Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception
7381
* in case of overloaded method.

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

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,18 @@
2222
import java.nio.channels.ClosedByInterruptException;
2323
import java.util.ArrayList;
2424
import java.util.Arrays;
25-
import java.util.HashMap;
2625
import java.util.List;
2726
import java.util.Map;
2827
import java.util.Set;
2928
import java.util.stream.Collectors;
3029

31-
import javax.annotation.PostConstruct;
3230
import javax.validation.ConstraintViolation;
3331
import javax.validation.Validator;
3432
import javax.validation.ValidatorFactory;
3533

3634
import org.jline.utils.Signals;
3735

3836
import org.springframework.beans.factory.annotation.Autowired;
39-
import org.springframework.context.ApplicationContext;
4037
import org.springframework.core.MethodParameter;
4138
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
4239
import org.springframework.util.ReflectionUtils;
@@ -66,15 +63,10 @@ public class Shell {
6663
*/
6764
public static final Object NO_INPUT = new Object();
6865

69-
@Autowired
70-
protected ApplicationContext applicationContext;
71-
72-
private CommandRegistry commandRegistry;
66+
private final CommandRegistry commandRegistry;
7367

7468
private Validator validator = Utils.defaultValidator();
7569

76-
protected Map<String, MethodTarget> methodTargets = new HashMap<>();
77-
7870
protected List<ParameterResolver> parameterResolvers;
7971

8072
/**
@@ -93,13 +85,6 @@ public void setValidatorFactory(ValidatorFactory validatorFactory) {
9385
this.validator = validatorFactory.getValidator();
9486
}
9587

96-
@PostConstruct
97-
public void gatherMethodTargets() throws Exception {
98-
methodTargets = commandRegistry.listCommands();
99-
methodTargets.values()
100-
.forEach(this::validateParameterResolvers);
101-
}
102-
10388
@Autowired
10489
public void setParameterResolvers(List<ParameterResolver> resolvers) {
10590
this.parameterResolvers = new ArrayList<>(resolvers);
@@ -158,6 +143,7 @@ public Object evaluate(Input input) {
158143

159144
List<String> words = input.words();
160145
if (command != null) {
146+
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
161147
MethodTarget methodTarget = methodTargets.get(command);
162148
Availability availability = methodTarget.getAvailability();
163149
if (availability.isAvailable()) {
@@ -240,6 +226,7 @@ public List<CompletionProposal> complete(CompletionContext context) {
240226
if (best != null) {
241227
CompletionContext argsContext = context.drop(best.split(" ").length);
242228
// Try to complete arguments
229+
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
243230
MethodTarget methodTarget = methodTargets.get(best);
244231
Method method = methodTarget.getMethod();
245232

@@ -260,6 +247,7 @@ private List<CompletionProposal> commandsStartingWith(String prefix) {
260247
// Workaround for https://github.com/spring-projects/spring-shell/issues/150
261248
// (sadly, this ties this class to JLine somehow)
262249
int lastWordStart = prefix.lastIndexOf(' ') + 1;
250+
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
263251
return methodTargets.entrySet().stream()
264252
.filter(e -> e.getKey().startsWith(prefix))
265253
.map(e -> toCommandProposal(e.getKey().substring(lastWordStart), e.getValue()))
@@ -313,30 +301,16 @@ private Object[] resolveArgs(Method method, List<String> wordsForArgs) {
313301
return args;
314302
}
315303

316-
/**
317-
* Verifies that we have at least one {@link ParameterResolver} that supports each of the
318-
* {@link MethodParameter}s in the method.
319-
*/
320-
private void validateParameterResolvers(MethodTarget methodTarget) {
321-
Utils.createMethodParameters(methodTarget.getMethod())
322-
.forEach(parameter -> {
323-
parameterResolvers.stream()
324-
.filter(resolver -> resolver.supports(parameter))
325-
.findFirst()
326-
.orElseThrow(() -> new ParameterResolverMissingException(parameter));
327-
});
328-
}
329-
330304
/**
331305
* Returns the longest command that can be matched as first word(s) in the given buffer.
332306
*
333307
* @return a valid command name, or {@literal null} if none matched
334308
*/
335309
private String findLongestCommand(String prefix) {
310+
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
336311
String result = methodTargets.keySet().stream()
337312
.filter(command -> prefix.equals(command) || prefix.startsWith(command + " "))
338313
.reduce("", (c1, c2) -> c1.length() > c2.length() ? c1 : c2);
339314
return "".equals(result) ? null : result;
340315
}
341-
342316
}

spring-shell-core/src/test/java/org/springframework/shell/ShellTest.java

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
import java.io.IOException;
2020
import java.util.Arrays;
2121
import java.util.Collections;
22+
import java.util.HashMap;
2223
import java.util.List;
24+
import java.util.Map;
2325
import java.util.stream.Collectors;
2426

2527
import org.junit.jupiter.api.BeforeEach;
@@ -29,15 +31,11 @@
2931
import org.mockito.Mock;
3032
import org.mockito.junit.jupiter.MockitoExtension;
3133

32-
import org.springframework.context.ApplicationContext;
33-
3434
import static org.assertj.core.api.Assertions.assertThat;
35-
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3635
import static org.assertj.core.api.Assertions.fail;
3736
import static org.mockito.ArgumentMatchers.any;
3837
import static org.mockito.ArgumentMatchers.isA;
3938
import static org.mockito.Mockito.doThrow;
40-
import static org.mockito.Mockito.mock;
4139
import static org.mockito.Mockito.when;
4240

4341
/**
@@ -54,6 +52,9 @@ public class ShellTest {
5452
@Mock
5553
ResultHandlerService resultHandlerService;
5654

55+
@Mock
56+
CommandRegistry commandRegistry;
57+
5758
@Mock
5859
private ParameterResolver parameterResolver;
5960

@@ -77,7 +78,8 @@ public void commandMatch() throws IOException {
7778
when(parameterResolver.resolve(any(), any())).thenReturn(valueResult);
7879
doThrow(new Exit()).when(resultHandlerService).handle(any());
7980

80-
shell.methodTargets = Collections.singletonMap("hello world", MethodTarget.of("helloWorld", this, new Command.Help("Say hello")));
81+
when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("hello world",
82+
MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))));
8183

8284
try {
8385
shell.run(inputProvider);
@@ -95,7 +97,8 @@ public void commandNotFound() throws IOException {
9597
when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?");
9698
doThrow(new Exit()).when(resultHandlerService).handle(isA(CommandNotFound.class));
9799

98-
shell.methodTargets = Collections.singletonMap("bonjour", MethodTarget.of("helloWorld", this, new Command.Help("Say hello")));
100+
when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("bonjour",
101+
MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))));
99102

100103
try {
101104
shell.run(inputProvider);
@@ -112,7 +115,8 @@ public void commandNotFoundPrefix() throws IOException {
112115
when(inputProvider.readInput()).thenReturn(() -> "helloworld how are you doing ?");
113116
doThrow(new Exit()).when(resultHandlerService).handle(isA(CommandNotFound.class));
114117

115-
shell.methodTargets = Collections.singletonMap("hello", MethodTarget.of("helloWorld", this, new Command.Help("Say hello")));
118+
when(commandRegistry.listCommands()).thenReturn(
119+
Collections.singletonMap("hello", MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))));
116120

117121
try {
118122
shell.run(inputProvider);
@@ -131,7 +135,8 @@ public void noCommand() throws IOException {
131135
when(parameterResolver.resolve(any(), any())).thenReturn(valueResult);
132136
doThrow(new Exit()).when(resultHandlerService).handle(any());
133137

134-
shell.methodTargets = Collections.singletonMap("hello world", MethodTarget.of("helloWorld", this, new Command.Help("Say hello")));
138+
when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("hello world",
139+
MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))));
135140

136141
try {
137142
shell.run(inputProvider);
@@ -149,7 +154,8 @@ public void commandThrowingAnException() throws IOException {
149154
when(inputProvider.readInput()).thenReturn(() -> "fail");
150155
doThrow(new Exit()).when(resultHandlerService).handle(isA(SomeException.class));
151156

152-
shell.methodTargets = Collections.singletonMap("fail", MethodTarget.of("failing", this, new Command.Help("Will throw an exception")));
157+
when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("fail",
158+
MethodTarget.of("failing", this, new Command.Help("Will throw an exception"))));
153159

154160
try {
155161
shell.run(inputProvider);
@@ -169,36 +175,19 @@ public void comments() throws IOException {
169175
shell.run(inputProvider);
170176
}
171177

172-
// no need to test as we're moving away from postconstruct
173-
// @Test
174-
public void parametersSupported() throws Exception {
175-
when(parameterResolver.supports(any())).thenReturn(false);
176-
shell.applicationContext = mock(ApplicationContext.class);
177-
when(shell.applicationContext.getBeansOfType(MethodTargetRegistrar.class))
178-
.thenReturn(Collections.singletonMap("foo", r -> {
179-
r.register("hw", MethodTarget.of("helloWorld", this, new Command.Help("hellow world")));
180-
}));
181-
182-
assertThatThrownBy(() -> {
183-
shell.gatherMethodTargets();
184-
}).isInstanceOf(ParameterResolverMissingException.class);
185-
}
186-
187-
// @Test
178+
@Test
188179
public void commandNameCompletion() throws Exception {
189-
shell.applicationContext = mock(ApplicationContext.class);
180+
Map<String, MethodTarget> methodTargets = new HashMap<>();
181+
methodTargets.put("hello world", MethodTarget.of("helloWorld", this, new Command.Help("hellow world")));
182+
methodTargets.put("another command", MethodTarget.of("helloWorld", this, new Command.Help("another command")));
190183
when(parameterResolver.supports(any())).thenReturn(true);
191-
when(shell.applicationContext.getBeansOfType(MethodTargetRegistrar.class))
192-
.thenReturn(Collections.singletonMap("foo", r -> {
193-
r.register("hello world", MethodTarget.of("helloWorld", this, new Command.Help("hellow world")));
194-
r.register("another command", MethodTarget.of("helloWorld", this, new Command.Help("another command")));
195-
}));
196-
shell.gatherMethodTargets();
184+
when(commandRegistry.listCommands()).thenReturn(methodTargets);
197185

198186
// Invoke at very start
199187
List<String> proposals = shell.complete(new CompletionContext(Arrays.asList(""), 0, "".length()))
200188
.stream().map(CompletionProposal::value).collect(Collectors.toList());
201-
assertThat(proposals).containsExactly("another command", "hello world");
189+
assertThat(proposals).containsExactlyInAnyOrder("another command", "hello world");
190+
// assertThat(proposals).containsExactly("another command", "hello world");
202191

203192
// Invoke in middle of first word
204193
proposals = shell.complete(new CompletionContext(Arrays.asList("hel"), 0, "hel".length()))
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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.samples.standard;
17+
18+
import org.springframework.shell.MethodTarget;
19+
import org.springframework.shell.standard.AbstractShellComponent;
20+
import org.springframework.shell.standard.ShellComponent;
21+
import org.springframework.shell.standard.ShellMethod;
22+
import org.springframework.shell.standard.ShellOption;
23+
24+
@ShellComponent
25+
public class RegisterCommands extends AbstractShellComponent {
26+
27+
private final PojoMethods pojoMethods = new PojoMethods();
28+
29+
@ShellMethod(key = "register add", value = "Register commands", group = "Register Commands")
30+
public String register() {
31+
MethodTarget target1 = MethodTarget.of("dynamic1", pojoMethods, "Dynamic1 command", "Register Commands");
32+
MethodTarget target2 = MethodTarget.of("dynamic2", pojoMethods, "Dynamic2 command", "Register Commands");
33+
MethodTarget target3 = MethodTarget.of("dynamic3", pojoMethods, "Dynamic3 command", "Register Commands");
34+
getCommandRegistry().addCommand("register dynamic1", target1);
35+
getCommandRegistry().addCommand("register dynamic2", target2);
36+
getCommandRegistry().addCommand("register dynamic3", target3);
37+
return "Registered commands dynamic1, dynamic2, dynamic3";
38+
}
39+
40+
@ShellMethod(key = "register remove", value = "Deregister commands", group = "Register Commands")
41+
public String deregister() {
42+
getCommandRegistry().removeCommand("register dynamic1");
43+
getCommandRegistry().removeCommand("register dynamic2");
44+
getCommandRegistry().removeCommand("register dynamic3");
45+
return "Deregistered commands dynamic1, dynamic2, dynamic3";
46+
}
47+
48+
public static class PojoMethods {
49+
50+
@ShellMethod
51+
public String dynamic1() {
52+
return "dynamic1";
53+
}
54+
55+
@ShellMethod
56+
public String dynamic2(String arg1) {
57+
return "dynamic2" + arg1;
58+
}
59+
60+
@ShellMethod
61+
public String dynamic3(@ShellOption(defaultValue = ShellOption.NULL) String arg1) {
62+
return "dynamic3" + arg1;
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)