diff --git a/README.md b/README.md index 54e5d2a..a7705c8 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,64 @@ But the following example will not be candidate for _Main_ command class MainCommand {} ``` -#### Nested sub-commands using beans +#### Nested sub-commands -Picocli allows [_nested sub-commands_](http://picocli.info/#_nested_sub_subcommands), in order to describe a _nested sub-command_, starter is offering you nested classes scanning capability. +Picocli allows [_nested sub-commands_](http://picocli.info/#_nested_sub_subcommands), in order to describe a _nested sub-command_, starter is offering two ways to describe your structure. + +Please refer to next points to see how to construct this following command line application using both way: + +``` +Commands: + flyway [-h, --help] + migrate + repair +``` + +`java -jar .jar flyway migrate` will execute _Flyway_ migration. + +##### Using `subCommands` from `@Command` annotation + +```java +@Component +@Command(name = "flyway", subCommands = { MigrateCommand.class, RepairCommand.class }) +class FlywayCommand extends HelpAwareContainerPicocliCommand {} + +@Component +@Command(name = "migrate") +class MigrateCommand implements Runnable { + + private final Flyway flyway; + + public MigrateCommand(Flyway flyway) { + this.flyway = flyway; + } + + @Override + public void run() { + flyway.migrate(); + } +} + +@Component +@Command(name = "repair") +class RepairCommand implements Runnable { + private final Flyway flyway; + + public RepairCommand(Flyway flyway) { + this.flyway = flyway; + } + + @Override + public void run() { + flyway.repair(); + } +} +``` + +By default starter is providing a custom implementation [`ApplicationContextAwarePicocliFactory`](picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/ApplicationContextAwarePicocliFactory.java) of [`CommandLine.IFactory`](https://picocli.info/apidocs/picocli/CommandLine.IFactory.html) that will delegate instance creation to _Spring_ `ApplicationContext` in order to load bean if exists. +**ATTENTION** If subCommand is not a defined bean, [`ApplicationContextAwarePicocliFactory`](picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/ApplicationContextAwarePicocliFactory.java) will only instanciate class without any _autowiring_ capability. + +##### Using java nested class hierarchy That means, if you're defining **bean** structure like following: @@ -161,20 +216,9 @@ class FlywayCommand extends HelpAwareContainerPicocliCommand { } ``` -Will generate command line - -``` -Commands: - flyway [-h, --help] - migrate - repair -``` - -Thus `java -jar .jar flyway migrate` will execute _Flyway_ migration. - **ATTENTION** every classes must be a bean (`@Component`) with `@Command` annotation without forgetting to file `name` attribute. -There is **no limitation** about nesting level. +Otherwise, there is **no limitation** about nesting level. ### Additional configuration diff --git a/picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/ApplicationContextAwarePicocliFactory.java b/picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/ApplicationContextAwarePicocliFactory.java new file mode 100644 index 0000000..c5e8a9b --- /dev/null +++ b/picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/ApplicationContextAwarePicocliFactory.java @@ -0,0 +1,37 @@ +package com.kakawait.spring.boot.picocli.autoconfigure; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationContext; +import picocli.CommandLine; + +import java.lang.reflect.Constructor; + +/** + * @author Thibaud Leprêtre + */ +public class ApplicationContextAwarePicocliFactory implements CommandLine.IFactory { + private static final Logger logger = LoggerFactory.getLogger(ApplicationContextAwarePicocliFactory.class); + + private final ApplicationContext applicationContext; + + public ApplicationContextAwarePicocliFactory(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public K create(Class aClass) throws Exception { + try { + return applicationContext.getBean(aClass); + } catch (Exception e) { + logger.info("unable to get bean of class {}, use standard factory creation", aClass); + try { + return aClass.newInstance(); + } catch (Exception ex) { + Constructor constructor = aClass.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + } + } +} diff --git a/picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/PicocliAutoConfiguration.java b/picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/PicocliAutoConfiguration.java index abdfd20..a5aadf1 100644 --- a/picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/PicocliAutoConfiguration.java +++ b/picocli-spring-boot-autoconfigure/src/main/java/com/kakawait/spring/boot/picocli/autoconfigure/PicocliAutoConfiguration.java @@ -2,12 +2,13 @@ import java.lang.reflect.Method; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.slf4j.Logger; @@ -41,6 +42,12 @@ @Import(PicocliAutoConfiguration.CommandlineConfiguration.class) class PicocliAutoConfiguration { + @Bean + @ConditionalOnMissingBean(CommandLine.IFactory.class) + CommandLine.IFactory applicationAwarePicocliFactory(ApplicationContext applicationContext) { + return new ApplicationContextAwarePicocliFactory(applicationContext); + } + @Bean @ConditionalOnMissingBean(PicocliCommandLineRunner.class) @ConditionalOnBean(CommandLine.class) @@ -52,7 +59,13 @@ CommandLineRunner picocliCommandLineRunner(CommandLine cli) { @Conditional(CommandCondition.class) static class CommandlineConfiguration { - private final Logger logger = LoggerFactory.getLogger(CommandlineConfiguration.class); + private static final Logger logger = LoggerFactory.getLogger(CommandlineConfiguration.class); + + private final CommandLine.IFactory applicationAwarePicocliFactory; + + public CommandlineConfiguration(CommandLine.IFactory applicationAwarePicocliFactory) { + this.applicationAwarePicocliFactory = applicationAwarePicocliFactory; + } @Bean CommandLine picocliCommandLine(ApplicationContext applicationContext) { @@ -60,11 +73,11 @@ CommandLine picocliCommandLine(ApplicationContext applicationContext) { List mainCommands = getMainCommands(commands); Object mainCommand = mainCommands.isEmpty() ? new HelpAwarePicocliCommand() {} : mainCommands.get(0); if (mainCommands.size() > 1) { - throw new RuntimeException("Multiple mains command founds: " + Arrays.asList(mainCommands)); + throw new RuntimeException("Multiple mains command founds: " + Collections.singletonList(mainCommands)); } commands.removeAll(mainCommands); - CommandLine cli = new CommandLine(mainCommand); + CommandLine cli = new CommandLine(mainCommand, applicationAwarePicocliFactory); registerCommands(cli, commands); applicationContext.getBeansOfType(PicocliConfigurer.class).values().forEach(c -> c.configure(cli)); @@ -161,26 +174,24 @@ private void registerCommands(CommandLine cli, Collection commands) { } if (children.isEmpty()) { - if(!current.getSubcommands().containsKey(commandName)) { - current.addSubcommand(commandName, command); - } - } else { - CommandLine sub = null; - if(!current.getSubcommands().containsKey(commandName)){ - sub = new CommandLine(command); - current.addSubcommand(commandName, sub); + if(!current.getSubcommands().containsKey(commandName)) { + current.addSubcommand(commandName, command); } - else { - // get the reference of subCommands from current, instead of creating new one - sub = current.getSubcommands().get(commandName); + } else { + CommandLine sub; + if(!current.getSubcommands().containsKey(commandName)) { + sub = new CommandLine(command, applicationAwarePicocliFactory); + current.addSubcommand(commandName, sub); + } else { + // get the reference of subCommands from current, instead of creating new one + sub = current.getSubcommands().get(commandName); } for (Object child : children) { - String childCommandName = getCommandName(child); - if(!sub.getSubcommands().containsKey(childCommandName)) { - sub.addSubcommand(childCommandName, new CommandLine(child)); - } - + String childCommandName = getCommandName(child); + if (!sub.getSubcommands().containsKey(childCommandName)) { + sub.addSubcommand(childCommandName, new CommandLine(child, applicationAwarePicocliFactory)); + } } current = sub; } @@ -220,7 +231,7 @@ public boolean equals(Object o) { Node node = (Node) o; - return clazz != null ? clazz.equals(node.clazz) : node.clazz == null; + return Objects.equals(clazz, node.clazz); } @Override @@ -228,18 +239,18 @@ public int hashCode() { return clazz != null ? clazz.hashCode() : 0; } - @Override - public String toString() { - StringBuilder builder = new StringBuilder(); - builder.append("Node [clazz="); - builder.append(clazz); - builder.append(", object="); - builder.append(object); - builder.append(", parent="); - builder.append(parent); - builder.append("]"); - return builder.toString(); - } + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("Node [clazz="); + builder.append(clazz); + builder.append(", object="); + builder.append(object); + builder.append(", parent="); + builder.append(parent); + builder.append("]"); + return builder.toString(); + } } } diff --git a/picocli-spring-boot-autoconfigure/src/test/java/com/kakawait/spring/boot/picocli/autoconfigure/PicocliAutoConfigurationTest.java b/picocli-spring-boot-autoconfigure/src/test/java/com/kakawait/spring/boot/picocli/autoconfigure/PicocliAutoConfigurationTest.java index b984a16..3e4de9e 100644 --- a/picocli-spring-boot-autoconfigure/src/test/java/com/kakawait/spring/boot/picocli/autoconfigure/PicocliAutoConfigurationTest.java +++ b/picocli-spring-boot-autoconfigure/src/test/java/com/kakawait/spring/boot/picocli/autoconfigure/PicocliAutoConfigurationTest.java @@ -5,14 +5,15 @@ import static org.hamcrest.Matchers.matchesPattern; import java.util.Collection; +import java.util.function.Function; import java.util.regex.Pattern; import org.assertj.core.api.Condition; -import org.assertj.core.api.iterable.Extractor; import org.junit.After; import org.junit.Rule; import org.junit.Test; import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.rule.OutputCapture; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; @@ -87,12 +88,12 @@ public void autoConfiguration_NestedBeanDefinition_CreateNestedSubCommands() { PicocliCommandLineRunner runner = context.getBean(PicocliCommandLineRunner.class); Collection commands = context.getBeansWithAnnotation(Command.class).values(); - Extractor> extractor = input -> input.getSubcommands().values(); + Function> extractor = input -> input.getSubcommands().values(); assertThat(commands).hasSize(5); - + CommandLine current = runner.getCommandLine(); this.logCommandTree(current); - + assertThat(runner.getCommandLine().getSubcommands().values()) .hasSize(1) .haveExactly(1, new Condition<>(e -> { @@ -102,8 +103,8 @@ public void autoConfiguration_NestedBeanDefinition_CreateNestedSubCommands() { .flatExtracting(extractor) .hasSize(2) .haveExactly(1, new Condition<>(e -> { - Class clazz = NestedCommandConfiguration.Level0Command.Level1Command.class; - return e.getCommand().getClass().equals(clazz); + Class clazz = NestedCommandConfiguration.Level0Command.Level1Command.class; + return e.getCommand().getClass().equals(clazz); }, "Class Level1Command")) .haveExactly(1, new Condition<>(e -> { Class clazz = NestedCommandConfiguration.Level0Command.Level1bCommand.class; @@ -119,18 +120,18 @@ public void autoConfiguration_NestedBeanDefinition_CreateNestedSubCommands() { Class clazz = NestedCommandConfiguration.Level0Command.Level1Command.Level2Command.class; return e.getCommand().getClass().equals(clazz); }, "Class Level2Command")); - } - private void logCommandTree(CommandLine current) { - for(String command : current.getSubcommands().keySet()) { - CommandLine subCommand = current.getSubcommands().get(command); - System.out.println("current command: " + current.getCommandName() + " sub command: " + subCommand.getCommandName()); - this.logCommandTree(subCommand); + private void logCommandTree(CommandLine current) { + for (String command : current.getSubcommands().keySet()) { + CommandLine subCommand = current.getSubcommands().get(command); + System.out.println( + "current command: " + current.getCommandName() + " sub command: " + subCommand.getCommandName()); + this.logCommandTree(subCommand); } - } + } - @Test(expected = RuntimeException.class) + @Test(expected = RuntimeException.class) public void autoConfiguration_MultipleMainCommands_RandomUses() { load(MainCommandsConflictConfiguration.class); } @@ -152,6 +153,18 @@ public void autoConfiguration_WithMultiplePicocliConfigurerAdapters_ApplyAll() { assertThat(runner.getCommandLine().getSubcommands()).containsKeys("¯\\_(ツ)_/¯"); } + @Test + public void autoConfiguration_WithSubCommandsParameter_GetBeanIfExists() { + load(SubCommandConfiguration.class); + PicocliCommandLineRunner runner = context.getBean(PicocliCommandLineRunner.class); + + assertThat(runner.getCommandLine().getSubcommands().get("basic").getSubcommands().values()) + .isNotEmpty() + .extracting(CommandLine::getCommand) + .allMatch(c -> c instanceof SubCommandConfiguration.DummyBeanCommand) + .allMatch(c -> ((SubCommandConfiguration.DummyBeanCommand) c).getDummyBean() != null); + } + @Configuration static class EmptyConfiguration { } @@ -250,6 +263,60 @@ public void configure(CommandLine commandLine) { static class BasicCommand {} } + @Configuration + static class SubCommandConfiguration { + @Bean + DummyBean dummyBean() { + return new DummyBean("¯\\_(ツ)_/¯"); + } + + static class DummyBean { + private String title; + + public DummyBean(String title) { + this.title = title; + } + + public String getTitle() { + return title; + } + } + + @Component + @Command(name = "basic", + subcommands = { ConstructorInjectionSubCommand.class, SetterInjectionSubCommand.class }) + static class BasicCommand {} + + interface DummyBeanCommand { + DummyBean getDummyBean(); + } + + @Component + @Command(name = "sub1") + static class ConstructorInjectionSubCommand implements DummyBeanCommand { + private final DummyBean dummyBean; + + public ConstructorInjectionSubCommand(DummyBean dummyBean) { + this.dummyBean = dummyBean; + } + + public DummyBean getDummyBean() { + return dummyBean; + } + } + + @Component + @Command(name = "sub2") + static class SetterInjectionSubCommand implements DummyBeanCommand { + @Autowired + private DummyBean dummyBean; + + public DummyBean getDummyBean() { + return dummyBean; + } + } + } + private void load(Class... configs) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); context.register(configs); diff --git a/picocli-spring-boot-sample/src/main/resources/application.yml b/picocli-spring-boot-sample/src/main/resources/application.yml index 49eae3f..7fd981f 100644 --- a/picocli-spring-boot-sample/src/main/resources/application.yml +++ b/picocli-spring-boot-sample/src/main/resources/application.yml @@ -1,6 +1,6 @@ spring: main: - web-environment: false + web-application-type: none logging: level: ROOT: off