Blogs

Developing CLI application with Spring Shell (part 2)

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

In the previous post we have created the skeleton of the sample CLI application with some helper classes added to support display of contextual messages to the user (similar to the Bootstrap’s contextual messages).

In this blog post we go a step further and cover the topic of interacting with a user in a CLI application. What we will concentrate on will be capturing of the user’s input in the form of: free text, passwords, an option from the list of values, etc.

What we will be building in this part?

For the purposes of this tutorial we will build a command that captures data necessary to create a new user. This command will interact with user and ask him to provide the following data for the new user:

  • Full name (free text)
  • password (hidden text)
  • gender (a choice from list of options)
  • superuser (input from list of available values — with default value)

NOTE: Spring Shell relies on JLine — a powerful Java library — and most of the examples provided in the rest of this post are mainly related to the use of JLine and are not Spring Shell specific.

The model we will use

Package com.ag04.clidemo.model contains CliUser class we will use to store user’s input and pass it on to user service for further processing.

public class CliUser {
    private Long id;
    private String username;
    private String password;
    private String fullName;
    private Gender gender;
    private boolean superuser;

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

Enumeration Gender is pretty basic:

public enum Gender {
    MALE, FEMALE, DIVERSE;
}

Simple UserService interface, together with its mock implementation, one we will invoke to process user provided data is available in the package: com.ag04.clidemo.service, and defines the following contract:

public interface UserService {
    boolean exists(String username);
    CliUser create(CliUser user);
    CliUser update(CliUser user);
}
package com.ag04.clidemo.service;

import com.ag04.clidemo.model.CliUser;
import org.springframework.stereotype.Service;

/**
 * Mock implementation of UserService.
 *
 */
@Service
public class MockUserService implements UserService {

    @Override
    public boolean exists(String username) {
        if ("admin".equals(username)) {
            return true;
        }
        return false;
    }

    @Override
    public CliUser create(CliUser user) {
        user.setId(10000L);
        return user;
    }

    @Override
    public CliUser update(CliUser user) {
        return user;
    }
}

With this model at hand we can now proceed, and implement a command that will collect user input and then invoke UserService.create() method.

Create new user

Lets start with creating a new Spring Shell command named UserCommand with just one method designed to create a new user.

package com.ag04.clidemo.command;

import com.ag04.clidemo.service.UserService;
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.standard.ShellOption;

@ShellComponent
public class UserCommand {
    @Autowired
    ShellHelper shellHelper;

    @Autowired
    UserService userService;

    @ShellMethod("Create new user with supplied username")
    public void createUser(@ShellOption({"-U", "--username"}) String username) {
        if (userService.exists(username)) {
            shellHelper.printError(
                String.format("User with username='%s' already exists --> ABORTING", username)
            );
            return;
        }
    }
}

After rebuilding and re-running application, invoking help command should provide output similar as bellow (listing also our new command among the list of available commands):

CLI-DEMO:>help
AVAILABLE COMMANDS
Built-In Commands
        clear: Clear the shell screen.
        exit, quit: Exit the shell.
        help: Display help about available commands.
        history: Display or save the history of previously run commands
        script: Read and execute commands from a file.
        stacktrace: Display the full stacktrace of the last error.
Echo Command
        echo: Displays greeting message to the user whose name is supplied
User Command
        create-user: Create new user with supplied username

Available mock implementation of UserService.exists() method returns true for “admin” username. Now, invoke create-user command with admin as parameter and you should see the following output.

With all these pieces in place we can now start collecting user input and implementing create-user command.

Interacting with a user

Similar to the ShellHelper class we created in the previous post, in this post we will create a new InputReader class that will contain various helper methods for interacting with a user. The first method we need is the one that prompts the user to enter data in free format.

Listing bellow, contains our first implementation of InputReader class, with three methods designed to capture user data in free entry format:

package com.ag04.clidemo.shell;
import org.jline.reader.LineReader;
import org.springframework.util.StringUtils;

public class InputReader {
    public static final Character DEFAULT_MASK = '*';

    private Character mask;
    private LineReader lineReader;

    public InputReader(LineReader lineReader) {
        this(lineReader, null);
    }

    public InputReader(LineReader lineReader, Character mask) {
        this.lineReader = lineReader;
        this.mask = mask != null ? mask : DEFAULT_MASK;
    }

    public String prompt(String  prompt) {
        return prompt(prompt, null, true);
    }

    public String prompt(String  prompt, String defaultValue) {
        return prompt(prompt, defaultValue, true);
    }

    public String prompt(String  prompt, String defaultValue, boolean echo) {
        String answer = "";
        if (echo) {
            answer = lineReader.readLine(prompt + ": ");
        } else {
            answer = lineReader.readLine(prompt + ": ", mask);
        }
        if (StringUtils.isEmpty(answer)) {
            return defaultValue;
        }
        return answer;
    }
}

These three methods provide us with the capability to ask a user for an input with the display of customized prompt message at the beginning of the line. Additionally we have an option to supply the default return value in case a user just pressed enter, and to mask user’s entered input (in case we need to capture sensitive data, ie password).

In order to be able to use this class we also need to add the following configuration lines to an existing SpringShellConfig class:

@Bean
public InputReader inputReader(@Lazy LineReader lineReader) {
 return new InputReader(lineReader);
}

Now we can start implementing UserCommand.

Prompt user for full name and password

Now, lets modify UserCommand to resemble the code snippet bellow:

@ShellComponent
public class UserCommand {

    @Autowired
    ShellHelper shellHelper;

    @Autowired
    InputReader inputReader;

    @Autowired
    UserService userService;

    @ShellMethod("Create new user with supplied username")
    public void createUser(@ShellOption({"-U", "--username"}) String username) {
        if (userService.exists(username)) {
            shellHelper.printError(String.format("User with username='%s' already exists --> ABORTING", username));
            return;
        }
        CliUser user = new CliUser();
        user.setUsername(username);

        // 1. read user's fullName --------------------------------------------
        do {
            String fullName = inputReader.prompt("Full name");
            if (StringUtils.hasText(fullName)) {
                user.setFullName(fullName);
            } else {
                shellHelper.printWarning("User's full name CAN NOT be empty string? Please enter valid value!");                
            }
        } while (user.getFullName() == null);

        // 2. read user's password --------------------------------------------
        do {
            String password = inputReader.prompt("Password", "secret", false);
            if (StringUtils.hasText(password)) {
                user.setPassword(password);
            } else {
                shellHelper.printWarning("Password'CAN NOT be empty string? Please enter valid value!");
            }
        } while (user.getPassword() == null);

        // Print user's input -------------------------------------------------
        shellHelper.printInfo("\nCreating new user:");
        shellHelper.print("\nUsername: " + user.getUsername());
        shellHelper.print("Password: " + user.getPassword());
        shellHelper.print("Fullname: " + user.getFullName());
        shellHelper.print("Gender: " + user.getGender());
        shellHelper.print("Superuser: " + user.isSuperuser() + "\n");

        CliUser createdUser = userService.create(user);
        shellHelper.printSuccess("Created user with id=" + createdUser.getId());
    }
}

Here, we have added two blocks of code that capture user’s data. One that loops until user’s full name, containing at least one non space character,was entered, and the other one that prompts user to enter password, which masks user input (replacing his entry with default mask: *) and returns “secret” as default password value if user just presses enter.

Finally, the last block of code we added, prints out user’s entered data and invokes UserService.create() method. In the real life application user’s password would not be printed, but we have done it here for the purposes of debugging.

Now, try it out, (press enter when prompted for password) and the output should be similar to this:

Create user initial version

As can be seen, Gender property of CliUser is set to null and superuser property is set to false, which is fine since these are default values of CliUser. Now, lets improve InputReader and add support to allow user to chose one of the options from the list, and then expand UserCommand with the block of code that uses this functionality to capture user’s Gender data.

Prompt user to select one value from the list of available options

Now we are going to attempt something more ambitious, that is, to prompt a user to select one value from the list of values. Once implemented, we are going to use this capability to ask user to provide his gender in create-usercommand. As you could have noticed Gender enumeration defines three values. We will print each value, associate with each of them one character key, and ask user to enter one key as his choice.

What we will build, in the end will look as follows:

First, add ShellHelper as class property and change constructor of the InputReader util class as follows:

ShellHelper shellHelper;
public InputReader(LineReader lineReader, ShellHelper shellHelper) {
    this(lineReader, shellHelper, null);
}
public InputReader(LineReader lineReader, ShellHelper shellHelper, Character mask) {
    this.lineReader = lineReader;
    this.shellHelper = shellHelper;
    this.mask = mask != null ? mask : DEFAULT_MASK;
}

Then, at the end of the InputReader class add the following methods:

//--- select one option from the list of values --------------------

public String selectFromList(String headingMessage, String promptMessage, Map<String, String> options, boolean ignoreCase, String defaultValue) {
    String answer;
    Set<String> allowedAnswers = new HashSet<>(options.keySet());
    if (defaultValue != null && !defaultValue.equals("")) {
        allowedAnswers.add("");
    }
 shellHelper.print(String.format("%s: ", headingMessage));
    do {
        for (Map.Entry<String, String> option: options.entrySet()) {
            String defaultMarker = null;
            if (defaultValue != null) {
                if (option.getKey().equals(defaultValue)) {
                    defaultMarker = "*";
                }
            }
            if (defaultMarker != null) {
                shellHelper.printInfo(String.format("%s [%s] %s ", defaultMarker, option.getKey(), option.getValue()));
            } else {
                shellHelper.print(String.format("  [%s] %s", option.getKey(), option.getValue()));
            }
        }
        answer = lineReader.readLine(String.format("%s: ", promptMessage));
    } while (!containsString(allowedAnswers, answer, ignoreCase) && "" != answer);
if (StringUtils.isEmpty(answer) && allowedAnswers.contains("")) {
        return defaultValue;
    }
    return answer;
}
private boolean containsString(Set <String> l, String s, boolean ignoreCase){
    if (!ignoreCase) {
        return l.contains(s);
    }
    Iterator<String> it = l.iterator();
    while(it.hasNext()) {
        if(it.next().equalsIgnoreCase(s))
            return true;
    }
    return false;
}

Note: above implementation of selectFromList() method will also print default option value with different color style(info).

For this additions to work we also need to change the method that creates InputReader bean in SpringShellConfig class as follows:

@Bean
public InputReader inputReader(@Lazy LineReader lineReader, ShellHelper shellHelper) {
  return new InputReader(lineReader, shellHelper);
}

Finally, modify the UserCommand by adding the following lines before print of user’s input:

// 3. read user's Gender ----------------------------------------------
Map<String, String> options = new HashMap<>();
options.put("M", Gender.MALE.name());
options.put("F", Gender.FEMALE.name() );
options.put("D", Gender.DIVERSE.name());
String genderValue = inputReader.selectFromList("Gender", "Please enter one of the [] values", options, true, null);
Gender gender = Gender.valueOf(options.get(genderValue.toUpperCase()));
user.setGender(gender);
// Print user's input ----------------------------------------------

Gender property has no default value so we supplied null as fourth argument to the selectFromList() method.

Rebuild, run clidemo and invoke create-user command to test newly implemented features!

Method selectFromList() is convenient for cases when number of available options is greater than 2 or 3, and we would like to print each on single line. Yet, in cases when there are only two options for user to choose from (for example: Y/N) it would be more suited to have a method in InputReaderthat will present a user with a single prompt line, consisting of all available options.

Contact

Looking for Java experts?

To do so we will now modify InputReader class and add the two following methods:

/**
 * Loops until one of the `options` is provided. Pressing return is equivalent to
 * returning `defaultValue`.
 * <br/>
 * Passing null for defaultValue signifies that there is no default value.<br/>
 * Passing "" or null among optionsAsList means that an empty answer is allowed, in these cases this method returns
 * empty String "" as the result of its execution.
 */
public String promptWithOptions(String  prompt, String defaultValue, List<String> optionsAsList) {
    String answer;
    List<String> allowedAnswers = new ArrayList<>(optionsAsList);
    if (StringUtils.hasText(defaultValue)) {
        allowedAnswers.add("");
    }
    do {
        answer = lineReader.readLine(String.format("%s %s: ", prompt, formatOptions(defaultValue, optionsAsList)));
    } while (!allowedAnswers.contains(answer) && !"".equals(answer));

    if (StringUtils.isEmpty(answer) && allowedAnswers.contains("")) {
        return defaultValue;
    }
    return answer;
}

private List<String> formatOptions(String defaultValue, List<String> optionsAsList) {
    List<String> result = new ArrayList();
    for (String option : optionsAsList) {
        String val = option;
        if ("".equals(option) || option == null) {
            val = "''";
        }
        if (defaultValue != null ) {
           if (defaultValue.equals(option) || (defaultValue.equals("") && option == null)) {
               val = shellHelper.getInfoMessage(val);
           }
        }
        result.add(val);
    }
    return result;
}

These methods provides previously described functionality, and similar to the previously created selectFromList() method, if default value is provided, option of that value will be printed with the info color style.

To see it in action, we will use this method to ask user, should newly created user be marked as superuser. In the UserCommand class after the block of code that asks user to provide Gender add the following lines of code:

// 4. Prompt for superuser attribute ------------------------------
String superuserValue = inputReader.promptWithOptions("New user is superuser", "N", Arrays.asList("Y", "N"));
if ("Y".equals(superuserValue)) {
    user.setSuperuser(true);
} else {
    user.setSuperuser(false);
}
// Print user's input ----------------------------------------------
...

As can be seen default value in this case is set to “N”, resulting in return of “N” by InputReader.promptWithOptions() method in case the user just pressed ENTER as its input.

Finally, running clidemo again should result in the following output:

Create new user method of our UserCommand is now finished. We collect all the necessary data from a user, map it to the CliUser object and pass it to UserService.create() method in order to create a new user.

Additionally, we could also add one more action to this method, prompting a user to confirm that provided data is correct, using InputReader.promptWithOptions() as described above, after entered data is printed out and before invocation of UserService.create() method.

Final fine tuning

Yet, one small issue remains to be addressed. As can be seen in the pictures above, user input values are displayed with red color. This is the result of using SpringShell auto-configured LineReader, which is configured to display all user’s input in red color unless a input matches on of the shell commands.

To avoid this behavior we need to configure our own LineReader and pass it to InputReader’s constructor.

Modify SpringShellConfig class method inputReader() to match the code snippet bellow:

@Bean
public InputReader inputReader(
        @Lazy Terminal terminal,
        @Lazy Parser parser,
        JLineShellAutoConfiguration.CompleterAdapter completer,
        @Lazy History history,
        ShellHelper shellHelper
) {
    LineReaderBuilder lineReaderBuilder = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(completer)
        .history(history)
        .highlighter(
        (LineReader reader, String buffer) -> {
            return new AttributedString(
                buffer, AttributedStyle.BOLD.foreground(PromptColor.WHITE.toJlineAttributedStyle())
            );
        }
    ).parser(parser);

    LineReader lineReader = lineReaderBuilder.build();
    lineReader.unsetOpt(LineReader.Option.INSERT_TAB);
    return new InputReader(lineReader, shellHelper);
}

Now all user’s input will be displayed with default color and bold style, as visible in the image bellow:

Final output of the implemented UserCommand

This concludes the second part of this series of posts. In the next partwe will show you how to build progress bar component.


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:

Entire source code for this tutorial is available at GitHub repository:

https://github.com/dmadunic/clidemo

Additional resources:

Spring Shell project site:

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

Spring Shell official documentation:

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

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

Jline project site:

https://jline.github.io/

Keywords:

Java, Spring, Spring Shell, Spring boot, CLI, Terminal, JLine

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

Next

Blog

Why we don‘t have a CTO?

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