Production-ready microservice in Rust: 2. Add CLI sub-commands

Posted 2023-05-13 10:00:00 by sanyi ‐ 6 min read

A simple pattern to add multiple CLI sub-commands to the application

To create a new sub-command with clap you have to do two things:

  • add the subcommand to the clap configuration
  • handle the different commands according to command-line parameters

We could add these code snippets simply to main.rs but that way the main.rs would become bloated really quickly.

I prefer to put these things into a dedicated commands rust module.

Let's go to our shelter_main/src folder and create a new commands directory:

$ cd shelter_main/src
$ mkdir commands
$ cd commands

In the commands directory create a new mod.rs file, and add two methods: one to configure the command and one to handle the CLI arguments:

use clap::{ArgMatches, Command};

pub fn configure(command: Command) -> Command {
    command.subcommand(Command::new("hello").about("Hello World!"))
}

pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
    if let Some(_matches) = matches.subcommand_matches("hello") {
        println!("Hello World!");
    }

    Ok(())
}

The configure method simply takes an existing Command configuration and adds a new hello subcommand to it.

The handle method takes the argument matches returned by clap and checks whether our hello subcommand was called. If that was the case, it prints Hello World! to the console.

Notice the return type anyhow::Result<()>: the handle method returns nothing by default but it can return an error result is something goes wrong.

Now to use this code from main.rs we have to change it a little:

mod commands;

use clap::{Arg, Command};
use dotenv::dotenv;

pub fn main() -> anyhow::Result<()> {
    dotenv().ok();

    let mut command = Command::new("Dog Shelter sample application")
...
        );

    command = commands::configure(command);

    let matches = command.get_matches();

    commands::handle(&matches)?;

    Ok(())
}

First, we have to add the mod commands declaration at the top to integrate our new module to the codebase.

In the main method we have to make the command instance mutable, becase the commands::configure method creates a new version of it.

After calling command.get_matches() we also call commands::handle to handle all the subcommands we configured.

Notice the question mark at the end of the commands::handle call: when the method returns an error result, the execution of main will be interrupted here and the main method returns an error too.

One more trick: it's usually useful to arrange the crate to contain both a lib.rs and a main.rs file. It will contain both a library and a binary at the same time. This can make testing, benchmarking easier later.

To do so, add a lib.rs file to the src directory and move the mod commands declaration from main.rs:

pub mod commands;

We have to make it public, so main.rs can use it later. Now change the main.rs file too:

use clap::{Arg, Command};
use dotenv::dotenv;
use shelter_main::commands;

pub fn main() -> anyhow::Result<()> {
  ...
}

The name of the lib module is equivalent to the name of our crate: shelter_main, so to import the commands module we add use shelter_main::commands to main.rs.

Now make things a little more complicated: add more subcommands. To do this, I will split the commands module into submodules. Add a new hello.rs file to the commands folder and move the hello subcommand configuration and handler there:

use clap::{ArgMatches, Command};

pub fn configure() -> Command {
    Command::new("hello").about("Hello World!")
}

pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
    if let Some(_matches) = matches.subcommand_matches("hello") {
        println!("Hello World!");
    }

    Ok(())
}

I changed the configure() method a little so it only returns a new Command and does not configure and existing one.

Now change commands/mod.rs to use the new hello submodule:

mod hello;

use clap::{ArgMatches, Command};

pub fn configure(command: Command) -> Command {
    command.subcommand(hello::configure())
}

pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
    hello::handle(matches)?;

    Ok(())
}

The configure method adds the new Command returned by hello::configure() as a subcommand to the main clap configuration.

The handle method simply tries to call the handle method from the hello module. Notice the question mark: errors are returned immediately.

Now add one more command: the serve command will run our webserver later. Create a new file called commands/serve.rs:

use clap::{value_parser, Arg, ArgMatches, Command};

pub fn configure() -> Command {
    Command::new("serve").about("Start HTTP server").arg(
        Arg::new("port")
            .short('p')
            .long("port")
            .value_name("PORT")
            .help("TCP port to listen on")
            .default_value("8080")
            .value_parser(value_parser!(u16)),
    )
}

pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
    if let Some(matches) = matches.subcommand_matches("serve") {
        let port: u16 = *matches.get_one("port").unwrap_or(&8080);

        println!("TBD: start the webserver on port {}", port)
    }

    Ok(())
}

This command can take a port parameter but uses 8080 by default.

Modify commands/mod.rs to use the serve submodule too:

mod hello;
mod serve;

use clap::{ArgMatches, Command};

pub fn configure(command: Command) -> Command {
    command
        .subcommand(hello::configure())
        .subcommand(serve::configure())
}

pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
    hello::handle(matches)?;
    serve::handle(matches)?;

    Ok(())
}

Do you see the pattern? If you need more subcommands you can simply add more submodules and call them in the configure and handle methods.

Now let's build our project and see how it works. Go to the project root directory and call cargo build:

$ cargo build
   Compiling shelter_main v0.1.0 (.../shelter-project/shelter_main)
    Finished dev [unoptimized + debuginfo] target(s) in 0.66s

First try the help subcommand:

$ ./target/debug/shelter_main help
A sample application to experiment with Rust-based microservices

Usage: shelter_main [OPTIONS] [COMMAND]

Commands:
  hello  Hello World!
  serve  Start HTTP server
  help   Print this message or the help of the given subcommand(s)

Options:
  -c, --config <config>  Configuration file location [default: config.json]
  -h, --help             Print help
  -V, --version          Print version

As you can see we now have a hello and a serve subcommand. We can get more information on them too:

$ ./target/debug/shelter_main serve --help
Start HTTP server

Usage: shelter_main serve [OPTIONS]

Options:
  -p, --port <PORT>  TCP port to listen on [default: 8080]
  -h, --help         Print help

Now try the different subcommands:

$ ./target/debug/shelter_main hello
Hello World!

$ ./target/debug/shelter_main serve
TBD: start the webserver on port 8080

$ ./target/debug/shelter_main serve -p 3000
TBD: start the webserver on port 3000

$ ./target/debug/shelter_main serve --port 3000
TBD: start the webserver on port 3000

You can find the sample code on GitHub

Next article ยป

Tags:
rust microservice dog-shelter