What is Spring Shell?
Spring shell is a project of the Spring ecosystem that helps create shell applications (command line apps interacting via the terminal) easily.
In this article we are going to build a runnable application with some commands to communicate with the Customer RESTAPI app. We will re-use the code from the last article (New Spring RestClient).
The project
We are going to build a cli application with the following features:
- Allow searching customers by different criteria and display the results in table format.
- Allow finding a specific customer by id and display the information in table format.
- Allow updating customer properties and display outcome of the operation. This will require authentication in the application.
- Allow deleting a customer by id and display outcome of the operation. This will require authentication in the application.
In this article we will concentrate in items 1 and 2 and will leave 3 and 4 for a second part. Lets move on to the next section and start setting up the project.
Project Dependencies
This project will need two dependecies, Spring shell and Spring web. We will add the Spring validation dependency later on to validate user input.
Maven pom.xml file must contain
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-starter</artifactId>
</dependency>
When running the application, Spring Boot detects the shell starter dependencies in the classpath and will automatically prompt a cli as shown below
Registering Commands
We need to register 4 commands, one for each action. There are two ways to define a command: using an annotation model and using a programmatic model. We will choose the annotation model for our application. Methods and classes are marked with specific annotations to define commands.
Spring boot 3.1.x comes with new support for defining commands using annotations. The previous annotations (@shell...) are considered legacy and will be deprecated and removed. Hence, we will focus on the new annonations.
The @Command annotation is used to mark a method as candidate for command registration. It can also be placed on a class to apply settings to methods defined in the same class.
Lets create a class with a command method find a candidate by id
1. @Command(group = "Customer Commands")
2. public class CustomerCommands {
3. private CustomerService customerService;
4. public CustomerCommands(CustomerService customerService) {
5. this.customerService = customerService;
6. }
7. @Command(command="find-customer",
8. description="Finds a Customer By Id")
9. public String findCustomer(@Option(required = true,
10. description = "The customer id") Long id) {
11. return customerService.findCustomer(id).toString();
12. }
}
Let's analyse the above lines of code:
- In line 1, the class is marked as a command (which means it will have command methods). The group option organises related commands that can be put together and displays them in the same group in the help option.
- In line 3, our app will hand over to a service to find the customer.
- In line 7, method findCustomer is annotated as a command method. The annotation accepts the command option to specify the name of the command when invoked from the shell. By default, command key follow dashed gnu-style names (For instance, method findCustomer becomes find-customer). Also, description is set to provide more info on what the command does.
- In line 9, return type will be a string representing the customer. @Option marks the parameter as required command option of type Long. If it is not informed an error will be raised. It is possible to defined the short and long format of the option. That is, the prefix - or -- before the parameter value. We will see it in action when testing the code.
- In line 11, The call to search the customer by id is delgated to service (Service code will be shown at a later point in the article).
But that is not enough to register a target command. It is required to use @EnableCommand and/or @CommandScan annotations.
We will register our target class CustomerCommands using @EnableCommand. It will be picked up when application is run.
@SpringBootApplication
@EnableCommand(CustomerCommands.class)
public class CustomerShellApplication {
public static void main(String[] args) {
SpringApplication.run(
CustomerShellApplication.class, args);
}
}
Alternatively, the @CommandScan will automatically scan all command targets from all packages and classes under the RestClientShellApplication class.
@SpringBootApplication
@CommandScan
public class CustomerShellApplication {
public static void main(String[] args) {
SpringApplication.run(
CustomerShellApplication.class, args);
}
}
Now the application is ready to be tested. Before that there is one thing to mention. When the application is started the SpringBoot banner and the logs are displayed in the CLI. This will deteriorate the user experience. Both can be turned off adding the properties
logging.level.root=OFF
spring.main.banner-mode=off
spring.main.web-application-type=none
As the project contains spring mvc dependencies to make use of the restclient, the web server can be disabled.
Let's run the application and type help
As we can see in the above picture there are two command groups. The Built-In Commands provided by Spring. They are common features that can be found in most shells.
The second group is the Customer Commands group where the find-customer command is located. The command and description are shown as set in the annotation.
Searching a customer is as simple as typing any of the below lines
First line call does not specify the arg name. Second and third lines pass the short and long format respectively. In all cases, the correct customer is returned from the web service.
Service Layer
We are going to explore the Service layer more detailed in this section. As this is not directly related to Spring Shell you may skip this part and go to the Exception Handling section.
The service is a class that facilitates communication between the command class and the web service.
@Service
public class CustomerService {
private HttpAPIHandler httpAPIHandler;
public CustomerService(HttpAPIHandler httpAPIHandler) {
this.httpAPIHandler = httpAPIHandler;
}
public CustomerResponse findCustomer(Long id) {
return httpAPIHandler.findCustomer(id);
}
}
It uses a handler to call the web service with the Spring RestClient. For more info on how RestClient works you can visit the previous article here
@Component
public final class HttpAPIHandler {
private final APIProperties apiProperties;
private final RestClient restClient;
private final ObjectMapper objectMapper;
public HttpAPIHandler(APIProperties apiProperties,
ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
restClient = RestClient.builder()
.baseUrl(properties.getUrl())
.defaultHeader(HttpHeaders.AUTHORIZATION,
encodeBasic(properties.getUsername(),
properties.getPassword()))
.defaultStatusHandler(
HttpStatusCode::is4xxClientError,
(request, response) -> {
var errorDetails = objectMapper.readValue(
response.getBody().readAllBytes(),
ErrorDetails.class);
throw new RestClientCustomException(
response.getStatusCode(), errorDetails);
})
.build();
}
public CustomerResponse findCustomer(Long id) {
return restClient.get()
.uri("/{id}",id)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(CustomerResponse.class);
}
}
The first constructor parameter injected is the APIproperties. This is a simple property configuration class which loads the url, username and password from application.properties. @Value works for a single property but we want to have them together, hence they are isolated into a separated POJO.
@Configuration
@ConfigurationProperties(prefix = "application.rest.v1.customer")
public class ClientProperties {
String url;
String username;
String password;
// setters/getter omitted
}
And the properties definitions in app
application.rest.v1.customer.url=http://localhost:8080/api/v1/customers
application.rest.v1.customer.username=user1234
application.rest.v1.customer.password=password5678
The second constructor parameter injected is an ObjectMapper. This class belongs to the jackson databind jar. It is needed to serialize the error response (json object containing status, message and timestamp) from the web service to the ErrorDetails record
public record ErrorDetails(
int status, String message, LocalDateTime timestamp) {}
The ObjectMapper is declared in a separeted class for app configuration. To manage dates and times correctly the JavaTimeModule must be registered.
@Configuration
public class AppConfig {
@Bean
ObjectMapper objectMapper(){
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return objectMapper;
}
}
With that all the pieces of the service layer are connected.
Exception Handling
There are situations where the program will not complete the task to find a customer successfully. For example, what happens when the web service is down or a customer does not exists? The following figure shows the output in these scenarios.
Spring shell offers a chain of Exception Resolver implementations to resolve exceptions and can return messages to be displayed in the console. Optionally an exit code can be returned together (wrapped in the class CommandHandlingResult) however this is only for non-interactive shell.
The steps to setup a Exception resolver are described here:
- Create a custom exception resolver implementing the interface CommandExceptionResolver.
- Define the resolver as a bean globally (It can be defined for a particular command programmatically too. See Spring Shell documentation for more info on it).
Let's implement the Exception resolver class. Code can be seen in the next lines
public class CLIExceptionResolver implements
CommandExceptionResolver {
@Override
public CommandHandlingResult resolve(Exception ex) {
if (ex instanceof RestClientCustomException e)
return CommandHandlingResult.of(
e.getErrorDetails().message()+'\n');
else if (ex instanceof ResourceAccessException e)
return CommandHandlingResult.of(
"Customer API is not available at the moment"+'\n');
return CommandHandlingResult.of(ex.getMessage()+'\n', 1);
}
}
CommandHandlingResult comes with an overloaded factory method of(). It takes a String representing the error message and optionally an exit code.
Next the bean must be defined. We will add it to the AppConfig class along with the ObjectMapper
@Configuration
public class AppConfig {
@Bean
CLIExceptionResolver customExceptionResolver() {
return new CLIExceptionResolver();
}
...
}
It is time to check if the exception handling works fine. Let's re-run the two tests.
Now both exceptions return the messages as per CLIExceptionResolver.
Formatting output
In this section we will be adding a new command to find customers and present the data in a table.
The new command method accepts two optional parameters. An enum representing the customer status and a Boolean indicating whether the customer is vip or not.
@Command(command = "find-customers",
description = "Find Customers By Status and vip")
public String findCustomers(
@Option(required = false,longNames = "status",
shortNames = 's') CustomerStatus status,
@Option(required = false,longNames = "vip",
shortNames = 'v') Boolean vip)
throws JsonProcessingException {
1. List<CustomerResponse> customers =
customerService.findCustomers(status, vip);
2. return ouputFormatter.coverToTable(customers);
}
public enum CustomerStatus { ACTIVATED, DEACTIVATED, SUSPENDED } ;
In line 1, the service gets a list of customers filtering by status and/or vip. Implementation of the code in the HttpAPIHandler is shown below. The most interesting part of the code is the return statement in which the response as a string is converted to List with the help of the objectMapper. This is because body method does not accepts parameterised types (Passing List.class will return a list of maps).
public List<CustomerResponse> findCustomers(
CustomerStatus status, Boolean vip)
throws JsonProcessingException {
String response = restClient.get()
.uri(getQueryString(status, vip))
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(String.class);
return Arrays.stream(
objectMapper.readValue(response,CustomerResponse[].class))
.toList();
}
In line 2, the formatted ouput is generated with the help of the class outFormatter. This class is declared as a bean in Appconfig class where we kept all the projects beans. Then, it is constructor-injected in CustomerCommands.
@Bean
OuputFormatter ouputFormatter(){
return new OuputFormatter();
}
Let's have a closer look this class to view how the table is created.
public final class OuputFormatter {
public String coverToTable(List<CustomerResponse> customers) {
1. var data = customers
.stream()
.map(OuputFormatter::toRow)
.collect(Collectors.toList());
2. data.add(0,
addRow("id", "name", "email", "vip", "status"));
3. ArrayTableModel model = new ArrayTableModel(
data.toArray(Object[][]::new));
4. TableBuilder table = new TableBuilder(model);
5. table.addHeaderAndVerticalsBorders(
BorderStyle.fancy_light);
6. return table.build().render(100);
}
private static String[] toRow(CustomerResponse c) {
return addRow(String.valueOf(c.id()),
c.personInfo().name(),
c.personInfo().email(),
String.valueOf(c.detailsInfo().vip()),
c.status());
}
private static String[] addRow(String id, String name,
String email, String vip, String status) {
return new String[] {id, name, email, vip, status};
}
}
Let's dissect the code line by line:
- The list of customers is converter to a list of String[] extrating the customer response fields.
- Header is added to the list head (first element of the list).
- ArrayTableModel from Spring shell represents the model of the table backed by a row first array. The cosntructor takes a bidimensional array as the data (Each array inside the outer array is a row). The list is transformed to an array.
- The TableBuilder configures a table from the model (object holding the table data).
- Border is set to the table.
- Table is built and rendered with a width of 100.
We are ready to search customers with the new command. All that is needed is to invoke the new command and inform any of the arguments.
And the styled table gives a nice touch to our shell application!
Conclusion
This article has been longer than usual but I think it was worth it. We have learned the basics of Spring Shell and some more advanced features like table formatting.
There will be a second part covering the items 3 and 4 of the Project section. We will also explore other advanced features such as Dynamic Availability.
Full code for the proejct can be found in the github repo here.
If you liked this article, do not hesitate to follow me and received new posts every month.
Top comments (2)
I'm not legacy 😁
I love it!