Blogs

Developing CLI application with Spring Shell (part 4)

Category
Software development
Developing CLI application with Spring Shell (part 4)

In the previous posts we have tackled the topics of base customization of Spring Shell CLI applications, conveying contextual information to the user by the use of colored output and capturing user input (in the form of the free text, one value from the list of options etc).

In this post we will turn our attention to the task of displaying data as tables using built-in Spring Shell functionality.

Basic concepts

It is best to begin this post with a simple example. Open your favorite IDE and create a new sample-tables command with the code shown bellow:

package com.ag04.clidemo.command;

import com.ag04.clidemo.shell.ShellHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.table.ArrayTableModel;
import org.springframework.shell.table.BorderStyle;
import org.springframework.shell.table.TableBuilder;
import org.springframework.shell.table.TableModel;


@ShellComponent
public class TableExamplesCommand {

    public String[] CONTINENTS = {"Europe", "North America", "South America", "Africa", "Asia", "Austraila and Oceania"};
    public String[] COUNTRIES1 = {"Germany", "USA", "Brasil", "Nigeria", "China", "Australia"};
    public String[] COUNTRIES2 = {"France", "Canada", "Argentina", "Egypt", "India", "New Zeeland"};

    @Autowired
    ShellHelper shellHelper;

    @ShellMethod("Display sample tables")
    public void sampleTables() {
        Object[][] sampleData = new String[][] {
                CONTINENTS,
                COUNTRIES1,
                COUNTRIES2
        };
        TableModel model = new ArrayTableModel(sampleData);
        TableBuilder tableBuilder = new TableBuilder(model);

        shellHelper.printInfo("air border style");
        tableBuilder.addFullBorder(BorderStyle.air);
        shellHelper.print(tableBuilder.build().render(80));

        shellHelper.printInfo("oldschool border style");
        tableBuilder.addFullBorder(BorderStyle.oldschool);
        shellHelper.print(tableBuilder.build().render(80));

        shellHelper.printInfo("fancy_light border style");
        tableBuilder.addFullBorder(BorderStyle.fancy_light);
        shellHelper.print(tableBuilder.build().render(80));

        shellHelper.printInfo("fancy_double border style");
        tableBuilder.addFullBorder(BorderStyle.fancy_double);
        shellHelper.print(tableBuilder.build().render(80));

        shellHelper.printInfo("mixed border style");
        tableBuilder.addInnerBorder(BorderStyle.fancy_light);
        tableBuilder.addHeaderBorder(BorderStyle.fancy_double);
        shellHelper.print(tableBuilder.build().render(80));
    }

}

After invoking this new sample-tables command you should be presented with the following output:

Demo of some of the available table border options.

As can be seen, this command displays the same set of data as tables, yet each with a different border style, illustrating the basic Spring Shell capabilities of displaying tabular data in the terminal.

At the core of this sample-tables command and Spring Shell support for display of tabular data is the TableBuilder class. This Spring Shell class, based on the supplied TableModel (we will cover it a little bit later), creates a Table object, that is a representation of our data (ie model). At the end what remains to be done is simply to print the Table object to the terminal.

The following lines of code do exactly that:

TableBuilder tableBuilder = new TableBuilder(someModel);
Table table = tableBuilder.build().render(width);
shellHelper.print(table);

In addition, the TableBuilder class has many other options. It allows us to: set border style (on all cells, only on the outline or header or just on some cells), set a wrapping strategy for an entire table or just a cell, set a cell alignment, configure a specific formatter class for a particular cell/or class, and finally to specify exact cell widths. More on these options will be said later, before that we will have to turn our attention to another Spring Shell core element- the TableModel interface.

The TableModel encapsulates our raw data and provides the Spring Shell Table class with a convenient interface for accessing it. The Spring Shell provides two TableModel implementations: ArrayTableModel and BeanListTableModel (this one will be discussed later).

The ArrayTableModel class is the most general implementation of the TableModel interface. Its constructor simply accept a two-dimensional array of Objects making it suitable for a wide range of cases:

ArrayTableModel(Object[][] data)

As can be seen from the code example above, constructing and displaying data in tabular form is very simple and boils down to these steps:

  1. Construct the TableModel around your actual data
  2. Construct the TableBuilder with the previously constructed model
  3. Add a border, formatter(s), set a wrapper and/or cell size …
  4. Render a Table from TableBuilder
  5. Print the Table to the terminal

In the table-samples command above we have adjusted the table border for each of the displayed tables, and in the last example we have shown how to set a different border for the header row:

TableModel model = new ArrayTableModel(sampleData);
TableBuilder tableBuilder = new TableBuilder(model);
....
tableBuilder.addInnerBorder(BorderStyle.fancy_light);
tableBuilder.addHeaderBorder(BorderStyle.fancy_double);
shellHelper.print(tableBuilder.build().render(80));

Fore more information on all available options please consult the official Javadoc for the BorderStyle enumeration and theTableBuilder class.

Data Formatters

So far we have displayed only Strings, and, as expected, all worked fine. However, in real life applications this is hardly going to be the case. We will most likely encounter the need to display other data types, as for example java.util.Date.

To display an element from the model within a cell, the Table class invokes configured Formatter, and the Spring shell provides a simple pre-configured DefaultFormatter class which simply invokes toString() method on the particular element being displayed. In the case of more complex objects, as for example java.util.Date, this is most likely going to produce unacceptable results.

Contact

Looking for Java experts?

In the next example we will create the model that will consist of both Strings and LocalDates and accordingly configure the TableBuilder class to use our own LocalDateFormatter class (see bellow) to display the LocalDate elements of the model.

In the TableExampleCommand class create a new method as shown bellow:

@ShellMethod("Table formatter demo")
public void tableFormatterDemo() {
    LocalDateFormatter dateFormatter = new LocalDateFormatter("dd.MM.YYYY");

    LocalDate now = LocalDate.now();
    Object[][] sampleData = new Object[][] {
        {"Date", "Value"},
        {"Today", now},
        {"Today minus 1", now.minusDays(1)},
        {"Today minus 2", now.minusDays(2)},
        {"Today minus 3", now.minusDays(3)}
    };

    TableModel model = new ArrayTableModel(sampleData);
    TableBuilder tableBuilder = new TableBuilder(model);
    tableBuilder.on(CellMatchers.ofType(LocalDate.class)).addFormatter(dateFormatter);
    tableBuilder.addFullBorder(BorderStyle.fancy_light);
    shellHelper.print(tableBuilder.build().render(30));
}

The sample code for the custom LocalDateFormatter can be found in the following code snippet:

package com.ag04.clidemo.shell.table;

import org.springframework.shell.table.Formatter;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;

public class LocalDateFormatter implements Formatter {

    private String pattern;

    public LocalDateFormatter(String pattern) {
        this.pattern = pattern;
    }

    @Override
    public String[] format(Object value) {
        LocalDate localDate = (LocalDate) value;
        SimpleDateFormat format = new SimpleDateFormat(pattern);
        Date date = Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
        return new String[] {format.format(date)};
    }
}

After rebuilding the clidemo application and running of the new table-formatter-demo command you should see the following output:

As can be seen, all dates are formatted with “dd.MM.YYY” pattern, just as we wanted. Now that we have addressed the basics of using the Spring Shell support for the display of tabular data, it is time to turn to more advanced topics. That is, how to display a list of the (same) Java Beans or a single Java Bean as tabular data.

However, before proceeding any further we need to improve our MockUserService class to more reassemble a real life service and returns richer set of CliUser data.

Improved MockUserService

To do so, we will first expand the existing UserService interface to include the following methods:

public interface UserService {
    CliUser findById(Long id);
    CliUser findByUsername(String username);
    List<CliUser> findAll();

    boolean exists(String username);
    CliUser create(CliUser user);
    CliUser update(CliUser user);
    long updateAll();
}

now, change the implementation of the MockUserService to match the following code snippet:

package com.ag04.clidemo.service;

import com.ag04.clidemo.model.CliUser;
import com.ag04.clidemo.observer.ProgressUpdateEvent;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

import javax.lang.model.UnknownEntityException;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Observable;
import java.util.Observer;

/**
 * Mock implementation of UserService.
 */
public class MockUserService extends Observable implements UserService {

    @Autowired
    private ObjectMapper objectMapper;
    private Observer observer;
    private List<CliUser> users = new ArrayList<>();

    @Override
    public CliUser findById(Long id) {
        for (CliUser user : users) {
            if (id.equals(user.getId())) {
                return user;
            }
        }
        return null;
    }

    @Override
    public CliUser findByUsername(String username) {
        for (CliUser user : users) {
            if (username.equals(user.getUsername())) {
                return user;
            }
        }
        return null;
    }

    @Override
    public List<CliUser> findAll() {
        return users;
    }

    @Override
    public boolean exists(String username) {
        for (CliUser user : users) {
            if (username.equals(user.getUsername())) {
                return true;
            }
        }
        return false;
    }

    @Override
    public CliUser create(CliUser user) {
        user.setId(new Long(getNextId()));
        users.add(user);
        return user;
    }

    @Override
    public CliUser update(CliUser user) {
        for(CliUser u : users) {
            if (u.getId().equals(user.getId())) {
                u = user;
                return user;
            }
        }
        throw new IllegalArgumentException("No matching user found!");
    }

    @Override
    public long updateAll() {
        long numberOfUsers = 2000;
        for (long i = 1; i <= numberOfUsers; i++) {
            // do some operation ...
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // notify observer of the change
            if (observer != null) {
                String message = "";
                if (i < numberOfUsers) {
                    message = ":: please WAIT update operation in progress";
                }
                observer.update(
                        this,
                        new ProgressUpdateEvent(i, numberOfUsers, message)
                );
            }
        }
        return numberOfUsers;
    }

    //--- set / get methods ---------------------------------------------------

    public Observer getObserver() {
        return observer;
    }

    public void setObserver(Observer observer) {
        this.observer = observer;
    }

    public ObjectMapper getObjectMapper() {
        return objectMapper;
    }

    public void setObjectMapper(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    //--- util methods --------------------------------------------------------

    public void init(String filePath) throws IOException {
        ClassPathResource cpr = new ClassPathResource("cli-users.json");
        users = objectMapper.readValue(cpr.getInputStream(), new TypeReference<List<CliUser>>() { });
    }

    private long getNextId() {
        long maxId = 0;
        for(CliUser user : users) {
            if (user.getId().longValue() > maxId) {
                maxId = user.getId().longValue();
            }
        }
        return maxId + 1;
    }
}

This implementation loads data from JSON file, store it locally and then performs all the operations on this local data. The location of JSON file should be in the src/main/resources folder and the example of JSON file can be found here:

[
  {"id": 1000,"username": "knavas", "password": "goalkeepe1r", "fullName": "Keylor Navas", "gender": "MALE","superuser" : "false" },
  {"id": 1001,"username": "tcourtois", "password": "goalkeeper25", "fullName": "Thibaut Courtois", "gender": "MALE","superuser" : "false" },
  {"id": 1002,"username": "lfernandez", "password": "goalkeeper30", "fullName": "Luca Fernández", "gender": "MALE","superuser" : "false" },
  {"id": 1003,"username": "dcarvajal", "password": "defender2", "fullName": "Daniel Carvajal Ramos", "gender": "MALE","superuser" : "false" },
  {"id": 1004,"username": "jvallejo", "password": "defender3", "fullName": "Jesús Vallejo Lázaro", "gender": "MALE","superuser" : "false" },
  {"id": 1005,"username": "sramos", "password": "defender4", "fullName": "Sergio Ramos García", "gender": "MALE","superuser" : "true" },
  {"id": 1006,"username": "rvarane", "password": "defender5", "fullName": "Raphaël Varane", "gender": "MALE","superuser" : "false" },
  {"id": 1007,"username": "nacho", "password": "defender6", "fullName": "José I. Fernández Iglesias", "gender": "MALE","superuser" : "false" },
  {"id": 1008,"username": "marcelov", "password": "defender12", "fullName": "Marcelo Vieira da Silva", "gender": "MALE","superuser" : "false" },
  {"id": 1009,"username": "aodriozola", "password": "defender19", "fullName": "Álvaro Odriozola", "gender": "MALE","superuser" : "false" },
  {"id": 1010,"username": "sreguilon", "password": "defender23", "fullName": "Sergio Reguilón Rodríguez", "gender": "MALE","superuser" : "false" },
  {"id": 1011,"username": "tkroos", "password": "midfielder8", "fullName": "Toni Kroos", "gender": "MALE","superuser" : "false" },
  {"id": 1012,"username": "lmodric", "password": "midfielder10", "fullName": "Luka Modrić", "gender": "MALE","superuser" : "true" },
  {"id": 1013,"username": "ccasimiro", "password": "midfielder14", "fullName": "Carlos Henrique Casimiro", "gender": "MALE","superuser" : "false" },
  {"id": 1014,"username": "fvalverde", "password": "midfielder15", "fullName": "Federico Santiago Valverde Dipetta", "gender": "MALE","superuser" : "false" },
  {"id": 1015,"username": "mllorente", "password": "midfielder18", "fullName": "Marcos Llorente Moreno", "gender": "MALE","superuser" : "false" },
  {"id": 1016,"username": "masensio", "password": "midfielder20", "fullName": "Marco Asensio Willemsen", "gender": "MALE","superuser" : "false" },
  {"id": 1017,"username": "dbrahim", "password": "midfielder21", "fullName": "Brahim Díaz", "gender": "MALE","superuser" : "false" },
  {"id": 1018,"username": "isco", "password": "midfielder22", "fullName": "Francisco Román  Alarcón", "gender": "MALE","superuser" : "false" },
  {"id": 1019,"username": "dceballos", "password": "midfielder24", "fullName": "Daniel Ceballos Fernández", "gender": "MALE","superuser" : "false" },
  {"id": 1020,"username": "dmariano", "password": "forward7", "fullName": "Mariano Díaz Mejía", "gender": "MALE","superuser" : "false" },
  {"id": 1021,"username": "kbenzema", "password": "forward9", "fullName": "Karim Benzema", "gender": "MALE","superuser" : "true" },
  {"id": 1022,"username": "gbale", "password": "forward11", "fullName": "Gareth Bale", "gender": "MALE","superuser" : "true" },
  {"id": 1023,"username": "lvazquez", "password": "forward17", "fullName": "Lucas Vázquez Iglesias", "gender": "MALE","superuser" : "false" },
  {"id": 1024,"username": "vinicius", "password": "forward28", "fullName": "Vinicius Paixao de Oliveira Junior", "gender": "MALE","superuser" : "false" }
]

For all this to work some additional configurations are needed. First we need to configure Jackson’s ObjectMapper bean. Now, create a new JacksonConfig configuration class, as shown in the next code snippet.

package com.ag04.clidemo.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Created by: dmadunic on 28/03/2019<br/>
 */
@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }
}

At the moment this class contains just a simple configuration of a single bean, but in a case we would later need to further customize default Jackson’s serialization and deserialization behavior, it is convenient to already have it in place.

Finally, we need to change the existing UserServiceConfig configuration class to match the changes in the MockUserService:

@Configuration
public class UserServiceConfig {

    @Bean
    public UserService userService(ProgressUpdateObserver observer, ObjectMapper objectMapper) throws IOException {
        MockUserService userService = new MockUserService();
        userService.setObserver(observer);
        userService.setObjectMapper(objectMapper);
        userService.init("cli-users.json");
        return userService;
    }
}

Now we are ready to further expand the existing UserCommand class by introducing new user related commands.

Display List of Java Beans/Entities

As mentioned before in addition to the ArrayTableModel, Spring Shell also provides the BeanListTableModel, a TableModel implementation suited for the display of the list of the same Java Beans/Entities. Using this TableModelimplementation we will implement the user-list command that displays all available CliUsers. The BeanListTableModel class provides three constructors of which the most convenient for our purposes is the following one:

BeanListTableModel(Iterable<T> list, LinkedHashMap<String,Object> header);

As can be seen this constructor takes as its first parameter the iterable list of the beans/entities which were to be displayed. The second parameter (named header) is a Map in which keys represent the names of the bean properties we would like to display in the table and elements represent labels for those properties, which will accordingly be displayed in the header row of the table. One thing is worth noting here, omitting a property name from this list would result in exclusion of that property from the table. In this manner it is possible to, rather conveniently, filter the data that will be displayed in the table.

Now we can start working on the new user-list command. Add the following method to the UserCommand class:

@ShellMethod("Display list of users")
public void userList() {
  List<CliUser> users = userService.findAll();

  LinkedHashMap<String, Object> headers = new LinkedHashMap<>();
  headers.put("id", "Id");
  headers.put("username", "Username");
  headers.put("fullName", "Full name");
  headers.put("gender", "Gender");
  headers.put("superuser", "Superuser");
  TableModel model = new BeanListTableModel<>(users, headers);

  TableBuilder tableBuilder = new TableBuilder(model);
  tableBuilder.addInnerBorder(BorderStyle.fancy_light);
  tableBuilder.addHeaderBorder(BorderStyle.fancy_double);
  shellHelper.print(tableBuilder.build().render(80));
}

Rebuilding the clidemo application and running the user-list command, now should produce the following output:

Output of the user-list command.

Display single Bean/Entity as tabular data

As the last exercise in this post we will address the issue of displaying a single Java Bean/Entity as tabular data. What we want to achieve is to have a table in which all rows consist of two columns, the first one containing the property label and the second one a property value, as shown in the image bellow:

Desired output of the user-details command.

Unfortunately the Spring Shell does not provide an out-of-box implementation of the TableModel that would suite our needs. However, the following can be achieved with the use of an existing ArrayTableModel. All we need to do is to create a two-dimensional array, in which the first column would contain a property label and a second property value.

In order not to have to repeat this procedure multiple times, we will create the BeanTableModelBuilder class that will also enable us to register property labels and a header row. The full listing of the BeanTableModelBuilder can be found in the snippet bellow:

package com.ag04.clidemo.shell.table;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.shell.table.ArrayTableModel;
import org.springframework.shell.table.TableModel;
import java.util.LinkedHashMap;
import java.util.Map;

public class BeanTableModelBuilder {

    private ObjectMapper objectMapper;
    private Object bean;
    private LinkedHashMap<String, Object> labels;
    private String[] header;


    public BeanTableModelBuilder(Object bean, ObjectMapper objectMapper) {
        this.bean = bean;
        this.objectMapper = objectMapper;
    }

    public BeanTableModelBuilder withLabels(LinkedHashMap<String, Object> labels) {
        this.labels = labels;
        return this;
    }

    public BeanTableModelBuilder withHeader(String[] header) {
        this.header = header;
        return this;
    }

    public TableModel build() {
        Map<String, String> map = objectMapper.convertValue(bean, new TypeReference<Map<String, String>>() {});

        int targetSize = (header == null) ? map.size() : map.size() +1;
        Object[][] entityProperties = new Object[targetSize][2];
        int i = 0;
        if (header != null) {
            entityProperties[0][0] = header[0];
            entityProperties[0][1] = header[1];
            i = 1;
        }

        for (Map.Entry<String, String> entry : map.entrySet()) {
            Object label = (labels != null) ? labels.get(entry.getKey()) : entry.getKey();
            entityProperties[i][0] = label + ":";
            entityProperties[i][1] = entry.getValue();
            i++;
        }
        return new ArrayTableModel(entityProperties);
    }
}

As can be seen, based on the supplied Java bean, headers and labels, this class will construct an appropriate ArrayTableModel. If no labels are provided default property names will be used.

We will use this class to implement the new user-details command that will display details of a single CliUser as a table. To do so, add the following two methods to the UserCommand class:

@ShellMethod("Display details of user with supplied username")
    public void userDetails(@ShellOption({"-U", "--username"}) String username) {
        CliUser user = userService.findByUsername(username);
        if (user == null) {
            shellHelper.printWarning("No user with the supplied username could be found?!");
            return;
        }
        displayUser(user);
    }

    private void displayUser(CliUser user) {
        LinkedHashMap<String, Object> labels = new LinkedHashMap<>();
        labels.put("id", "Id");
        labels.put("username", "Username");
        labels.put("fullName", "Full name");
        labels.put("gender", "Gender");
        labels.put("superuser", "Superuser");
        labels.put("password", "Password");

        String[] header = new String[] {"Property", "Value"};
        BeanTableModelBuilder builder = new BeanTableModelBuilder(user, objectMapper);
        TableModel model = builder.withLabels(labels).withHeader(header).build();

        TableBuilder tableBuilder = new TableBuilder(model);

        tableBuilder.addInnerBorder(BorderStyle.fancy_light);
        tableBuilder.addHeaderBorder(BorderStyle.fancy_double);
        tableBuilder.on(CellMatchers.column(0)).addSizer(new AbsoluteWidthSizeConstraints(20));
        tableBuilder.on(CellMatchers.column(1)).addSizer(new AbsoluteWidthSizeConstraints(30));
        shellHelper.print(tableBuilder.build().render(80));
    }

As can be seen from the code snippet above, in this example we have also provided some additional table formatting. Namely, we have set the columns’ width to 20 and 30 character respectively.

Finally, add also the following property to the UserCommand class:

@Autowired
private ObjectMapper objectMapper;

Now, running of the command:

user-details lmodric

should display the details of my son’s favorite football player, as can be seen in the screenshot bellow 😉

As an exercise, you could now refactor the existing create-user command so that it uses this new displayUser() method and by doing so remove some unnecessary/duplicate lines of code.

This concludes the fourth part of this series. In the next part we will address the problem of configuring Spring Security for the Spring Shell CLI applications.


Rest of the series:

Part 1: Conveying contextual messages to the users in the CLI application

Part 2: Capturing user’s input in the CLI application

Part 3: Displaying the progress of CLI command execution with the use of counters, spinners and progress bars

Part 4: Displaying the data with the use of tables in a Spring Shell based CLI application

Part 5: Securing CLI application with Spring Security

Sample Code:

The entire source code for this tutorial is available at GitHub repository:

https://github.com/dmadunic/clidemo

Additional resources:

The Spring Shell project site:

https://projects.spring.io/spring-shell/

The Spring Shell official documentation:

https://docs.spring.io/spring-shell/docs/current-SNAPSHOT/reference/htmlsingle/

https://docs.spring.io/spring-shell/docs/current/api/

Thank you for reading! I do hope you enjoyed it and please share if you did.

Next

Blog

Developing CLI application with Spring Shell (part 3)

Company

Our people really love it here

How it all started

Est. in 2014., gathering eight employees with eyes set on the future. No matter how set they were, they couldn’t predict the success and extent of growth that would ensue. Today there are more than 100 of us, and people are here to stay.

Stability in unstable times

The turmoil of 2020 caused great inconvenience for people all over the world. However, this did not affect our business. Quite the opposite — we not only kept all jobs and salaries intact, but we also grew in size. And we keep expanding. 

Contact

We’d love to hear from you