Coding a CLI application
In HackerSchool we started building an API for internal management. Before building a web frontend we wanted to create a CLI. This post will talk about that CLI!
The repository can be found here. 📂
Language
What language to pick? 🤔 Python was the first that came to mind, however, at first glance, most CLI libraries seemed to be getting a bit “in the way” by doing more than necessary, probably because a CLI may require packaging with entrypoints. Because of this I decided to go with Golang.
Commands
Now, ideally, we’d like to make this as uncomplicated as possible! So let’s apply some software patterns. 🧩
The first pattern is the obvious command pattern. We have commands which is just a fancy term for handler functions!
type Command func(c *Client.client, args ...string) ([]byte, error)
Now we have a function which runs a given command and returns an exit code.
func RunCommand(c *client.Client, cmd Command, args ...string) int {
r, err := cmd(c, args...)
if err != nil {
// some logic here
return 1
}
return 0
}
With our new AwesomeCommand
a call from main
is super simple:
func AwesomeCommand(c *client.Client, args ...string) ([]byte, error) {
if len(args) != 1 && args[0] != "argument" {
// something
}
return []byte("Command was successful!"), nil
}
func main() {
client := client.Client{}
os.Exit(commands.RunCommand(&client, commands.AwesomeCommand, os.Args...))
}
Authentication
Since this is an API CLI we are bound to run into authentication at some point. For our use case the authentication is session based. So what would we do if, for example, a command requires authentication? 🕵️♂️
Decorators
Let’s use another pattern, the decorator! 🎀
We will have a decorator that can either check if the session has ended before running the command (renewing it if it has) or retry the command after renewing the session if the request fails authentication.
Because I couldn’t get net/http/cookiejar
to expose the Expiry
of a cookie, the latter option had to be used, meaning we always need to make an extra request to know wether our session has expired.
func WithLoginRetry(cmd Command) Command {
return func(c *client.Client, args ...string) ([]byte, error) {
r, err := cmd(c, args...)
if errors.Is(err, client.ErrUnauthorized) {
// renew session
return cmd(c, args...) // and rerun the command
}
return r, err
}
}
This function, the decorator, decorates a command, adding new functionality to it. It runs the command and, if it fails with client.ErrUnauthorized
, renews the session and retries.
If a new command also requires authentication it only needs to use this decorator. The command should know how it must behave, e.g., it should know to return client.ErrUnauthorized
error for the retry to be made.
Now a call from main
of our previous command with authentication looks like this:
func main() {
client := client.Client{}
os.Exit(commands.RunCommand(&client,
command.WithLoginRetry(SomeCommand), os.Args...)
}
Extras
Here I’ll present some interesting enhancements related to the project.
Pipe Support
All commands that require a payload expect the path to the files containing the payload.
hscli uupdate username payload.json
But, what if you’re a chad and want to follow the UNIX philosphy by applying your mad bash skills with our program? We don’t want to anger these goated developers. We should implement piping support! 🐧
Editing a user information should be possible the following way:
hscli uget username | ... | hscli uupdate username
In ...
you edit the desired information and feed that into the uupdate
command.
To enable this we must accept the paylod to be read from the standard input. Let’s make use of yet another decorator. The underlying idea is that commands that require a payload can also expect it from the standard input.
func DefaultLastArgumentToStdin(cmd Command) Command {
return func(c *client.Clitechnical bashing skillsent, args ...string) ([]byte, error) {
if len(args) == 0 {
args = append(args, "/dev/stdin")
return cmd(c, args...)
}
// if last argument is not an existing file default to stdin
lastArg := args[len(args)-1]
if _, err := os.Stat(lastArg); err != nil {
args = append(args, "/dev/stdin")
return cmd(c, args...)
}
return cmd(c, args...)
}
}
Now, for commands that expect a file path, if the file doesn’t exist (that is, the argument is absent or invalid) we simply default to reading the payload from the standard input. The commands need not to be made aware of this, this is the beauty of the decorator pattern and the UNIX philosphy of everything is a file!
Configuration
This is a topic super specific to Golang, but likely of interest if you use strongly typed languages. Our program must be configured by the user, for example, the API root URL is not static and the user using the CLI will have its own credentials. We make use of a struct to hold our configuration values
type Configuration struct {
Root string `json:"root,omitempty" env:"HS_ROOT"`
User string `json:"user,omitempty" env:"HS_USER"`
Password string `json:"password,omitempty" env:"HS_PASSWORD"`
CookieJarPath string `json:"cookiejar,omitempty" env:"HS_COOKIEJAR"`
}
What are those strings? Those are tags, they can be accessed at runtime using reflection. Tags are commonly used by encoding libraries to enable serialization and deserialization of generic data.
The json
part is what the encoding/json
library expects to serialize/deserialize JSON from/to our Configuration struct, the first field in the tag maps our struct field to it’s JSON key, the omitempty
option means that the value can be ommited, which is to say, optional.
Now, we can load JSON data into our Configuration struct easily.
import "encoding/json"
func main() {
cfg := Configuration{}
if err := json.NewDecoder("config.json").Decode(&cfg); err != nil {
// ...
}
return 0
}
Nice! So we can write our configuration into a file and our program will use it! But this is super inconvenient. ☝️🤓 I don’t want to have my password stored in a file that is always persisted in the system, and I don’t want to edit a file everytime I need to update my configuration. We should allow users to configure via environment variables! 🌱
So let us define our own tags to load our configuration from environment variables. These tags will not be used by external libraries, we will use them ourselves!
We define our env
tag and in it we put the environment variable name that will hold the configuration value.
This will get messy, reflection is messy 🫠
func LoadConfig(cfg *Config) error {
cfgVal := reflect.Indirect(reflect.ValueOf(cfg))
missingFields = make([]string, 0)
missingFieldsEnvTag := make([]string, 0)
for i := 0; i < cfgVal.NumField(); i++ {
if cfgVal.Field(i).String() == "" {
missingFields = append(missingFields, cfgVal.Type().Field(i).Name)
envTag := cfgVal.Type().Field(i).Tag.Get("env")
missingFieldsEnvTag = append(missingFieldsEnvTag, envTag)
}
}
// populate missing fields
for i := 0; i < len(missingFields); i++ {
configurationValue, exists := os.LookupEnv(missingFieldsEnvTag[i])
if !exists {
// error, no configuration value!
}
cfgVal.FieldByName(missingFields[i]).SetString(configurationValue)
}
}
Golang reflection has two main types, Value
and Type
. I won’t pretend to understand it deeply, but the idea here is quite simple. We iterate through each field in our Configuration struct and, if the field value is not set, we save its name and its env
tag. Later we will load the values into the fields using os.LookupEnv(tag)
.
This clearly doesn’t allow for generic configurations, we are only loading strings, but it suffices for our use case! To achieve a more generic configuration parser you will need even messier reflection! I have written a Bencode parser that requires a bit more care, you can check it here. 📁
Observation: CLI arguments can also load the configuration, and these overwrite the file and environment ones, the snippet above also doesn’t take into account loading the configuration from the file.