Published on
Sat Jun 3, 2023

Part 6 - A simple CLI for Onehub

Introduction

Our OneHub chat service can be accessed both by the grpc_cli tool as well as via a REST interface. Even with these standard tools there is room for simplifying our access with a custom command line interface (CLI). For example we can hide away the need to expose customer http headers and content types. We will build our CLI - also in Go - using the popular Cobra library.

Getting Started

Our code for this can be found in the PART6_CLI branch of the OneHub repo.

We could have created a new repository for this. Instead we will add it as a module within the same repository. Create a cli folder and initialize a module:

1mkdir cli
2cd cli
3go mod init ohcli

We will use cobra-cli to simplifying scaffolding:

1go install github.com/spf13/cobra-cli@latest

Now running cobra-cli init in the cli folder will generate the following files:

cli
 |-- LICENSE
 |-- go.mod
 |-- go.sum
 |-- main.go
 |-- cmd
     |-- root.go

root.go contains a sample command. We can run our command with go run main.go or we can build/install it with:

1go build
2go install

This will install the oh binary into the $GOBIN folder. Ensure $GOBIN is in your $PATH. We can add the above to our Makefile.

Try it out!

1% oh
A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.

The cmd folder will host all our commands. For our application we will follow a REST like convention:

  • cmd/<entity>.go will contain the commands for a given entity (eg users, topics, messages).
  • Each cmd/<entity>.go will contain methods similar to what we have defined in our proto def files.

eg (from the cli folder):

1oh topics get
2oh topics create <creation params>

Add Sub-commands

With the cobra-cli we can add the entity related sub commands:

1cobra-cli add users
2cobra-cli add topics
3cobra-cli add msgs

Running the main cli now shows:

1% oh --help
The CLI for interacting with OneHub in a simpler but more flexible way

Usage:
  oh [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  msgs        A brief description of your command
  topics      A brief description of your command
  users       A brief description of your command

Flags:
  -h, --help     help for oh
  -t, --toggle   Help message for toggle

Use "oh [command] --help" for more information about a command.

For example our topics.go file simply contains:

1var topicsCmd = &cobra.Command{
2	Use:   "topics",
3	Short: "Manage topics",
4	Long:  `Group of commands to manage and interact with topic`,
5}

Now we can add other commands (like listTopics, getTopic etc to match our REST api):

 1var listCmd = &cobra.Command{
 2	Use:   "list",
 3	Short: "List topics",
 4	Long:  `List topics in the system optionally filtered by name`,
 5	Run: func(cmd *cobra.Command, args []string) {
 6		name, _ := cmd.Flags().GetString("name")
 7		if name != "" {
 8			fmt.Println("Listing topics by name: ", name)
 9		} else {
10			fmt.Println("Listing all topics")
11		}
12	},
13}
14
15var getCmd = &cobra.Command{
16	Use:   "get TOPICID [...TOPICIDS]",
17	Long:  `Get one or more topics by ID (or list of IDs`,
18	Args:  cobra.MinimumNArgs(1),
19	PreRunE: func(cmd *cobra.Command, args []string) error {
20    if len(args) < 1 {
21      return errors.New("Atleast one topic ID must be specified")
22    }
23    return nil
24	},
25	Run: func(cmd *cobra.Command, args []string) {
26    log.Printf("Getting a Topic: %s", args[0])
27	},
28}
29
30func init() {
31  // Add "topics" as sub command of the main rootCmd defined in root.go
32	rootCmd.AddCommand(topicsCmd)
33
34	// Add the get command to our topics group
35	topicsCmd.AddCommand(getCmd)
36
37	// Add the list command to our topics group
38	topicsCmd.AddCommand(listCmd)
39	listCmd.Flags().StringP("name", "n", "", "Match topics with name")
40}

We have done a few things here:

  • In the list command we added an optional “flag” (name) that allows us to either search for topics by name or list all topics.
  • In the get command we enforced minimum number of arguments - using a PreRunE method that returns an error appropriately.
  • We have added a Run method that is the heart of all our commands.

Functional approach to commands

The cobra-cli generates each command as a global variable in seperate files (eg topicCmd, userCmd etc). The disadvantage with this is approach - apart from cluttering the global namespace - is declaration of flags is seperated from the commands themselves (above the listCmd flags are declared in the init method while the listCmd is a global).

Instead we will adopt a more functional approach where each command is defined in its own function - along with all its flags - and built up together in a bottom-up approach. Using this approach the list and get commands are transformed to:

 1func listCommand() *cobra.Command {
 2	out := &cobra.Command{
 3		Use:   "list",
 4		Short: "List topics",
 5		Long:  `List topics in the system optionally filtered by name`,
 6		Run: func(cmd *cobra.Command, args []string) {
 7      name, _ := cmd.Flags().GetString("name")
 8      if name != "" {
 9        fmt.Println("Listing topics by name: ", name)
10      } else {
11        fmt.Println("Listing all topics")
12      }
13		},
14	}
15	out.Flags().StringP("name", "n", "", "Match topics with name")
16	return out
17}
18
19func getCommand() *cobra.Command {
20	return &cobra.Command{
21		Use:   "get",
22		Short: "Get topics",
23		Long:  `Get one or more topics by ID (or list of IDs`,
24    PreRunE: func(cmd *cobra.Command, args []string) error {
25      if len(args) < 1 {
26        return errors.New("Atleast one topic ID must be specified")
27      }
28      return nil
29    },
30    Run: func(cmd *cobra.Command, args []string) {
31      log.Printf("Getting a Topic: %s", args[0])
32    },
33	}
34}

Our root method is now:

 1package cmd
 2
 3import (
 4	"os"
 5
 6	"github.com/spf13/cobra"
 7)
 8
 9
10// rootCmd represents the base command when called without any subcommands
11func rootCommand() *cobra.Command {
12	out := &cobra.Command{
13		Use:   "oh",
14		Short: "The OneHub CLI",
15		Long:  `The CLI for interacting with OneHub in a simpler but more flexible way`,
16	}
17	return out
18}
19
20var rootCmd = rootCommand()
21
22func Execute() {
23	err := rootCmd.Execute()
24	if err != nil {
25		os.Exit(1)
26	}
27}

Adding a simple http client wrapper

Ultimately our cli will make http calls to the rest endpoint (generated by the grpc-gateway plugin in part 2 of this series). So a easy way to make http calls that follows our conventions (auth headers, json in/json out, etc) is useful. There are excellent libraries for wrapping the native Go http library. But since our requirements are simple, let us add our own wrapper for use in here. Our goals are simple:

  1. Json In, Json Out
  2. Basic Auth using username/password (for now)
  3. Simple with few dependencies

Here’s one we have whipped out - The OHClient. It will be used by all commands (that will create soon) to make http calls to our server.

Key things to note are:

  • The OHClient instance holds all details needed to make a http call (username/password, hostname, transport details etc)
  • Similar to python’s requests library the single Call method takes all that is needed to make a request (method, path, json payload, headers).
  • The Call method creates a http.Request instance, sets up the body and custom headers (if provided)
  • The Call method also enforces basic auth and content-type
  • The received body is parsed as JSON and fails if the body is not valid JSON
  • A default Insecure transport is used so that we can test against a locally running server instance. Transport can be reused and is safe for concurrent use by multiple goroutines.

That is it! All that is left is to create an instance of a Client and use it in our commands. As part of this we will also add persistent flags to customize the host, username and password (optionally by taking them in from appropriate environment variables) across all commands:

Implementing commands

We now have our basic command structure and we have a simple client. Let us put them together.

Take the list command:

 1func listCommand() *cobra.Command {
 2	out := &cobra.Command{
 3		Use:   "list",
 4		Short: "List topics",
 5		Long:  `List topics in the system optionally filtered by name`,
 6		Run: func(cmd *cobra.Command, args []string) {
 7			name, _ := cmd.Flags().GetString("name")
 8			if name != "" {
 9				Client.Call("GET", fmt.Sprintf("/v1/topics?name=%s", name), nil, nil, nil)
10			} else {
11				Client.Call("GET", "/v1/topics", nil, nil, nil)
12			}
13		},
14	}
15	out.Flags().StringP("name", "n", "", "Match topics with name")
16	return out
17}

Here we take the command flags, construct the right URL and call it through our client.

Before running our shiny new CLI, make sure the database and server are started:

1docker compose up -d
2go run cmd/server.go

We could pass the --username and --password flags each time but it is easier to just set them as environment variables for ease:

1% export OneHubUsername=auser
2
3% export OneHubPassword=auser123

And off you go:

1% oh topics list
1{
2  "nextPageKey": "",
3  "topics": []
4}

Surely enough we need to create some Topics first. Let us implement the creation command:

 1func createCommand() *cobra.Command {
 2	out := &cobra.Command{
 3		Use:        "new topic_name",
 4		ValidArgs:  []string{"TOPIC_NAME"},
 5		Args:       cobra.MinimumNArgs(1),
 6		ArgAliases: []string{"TOPIC_NAME"},
 7		Short:      "Create a new topic",
 8		Run: func(cmd *cobra.Command, args []string) {
 9			id, _ := cmd.Flags().GetString("id")
10			name := args[0]
11			params := StringMap{
12				"id":   id,
13				"name": name,
14			}
15			Client.Call("POST", "/v1/topics", nil, nil, StringMap{"topic": params})
16		},
17	}
18	out.Flags().StringP("id", "i", "", "A custom ID to use instead of auto generating one")
19	return out
20}

And create some Topics:

1% oh topics new "Topic 1" --id t1
 1{
 2  "topic": {
 3    "createdAt": "1970-01-01T00:00:00Z",
 4    "creatorId": "auser",
 5    "id": "t1",
 6    "name": "Topic 1",
 7    "updatedAt": "2023-08-12T05:46:45.981803Z",
 8    "users": {}
 9  }
10}
1% oh topics new "Topic 2" --id t2
 1{
 2  "topic": {
 3    "createdAt": "1970-01-01T00:00:00Z",
 4    "creatorId": "auser",
 5    "id": "t2",
 6    "name": "Topic 2",
 7    "updatedAt": "2023-08-12T05:47:01.380518Z",
 8    "users": {}
 9  }
10}
1% oh topics new "Topic 3" --id t3
 1{
 2  "topic": {
 3    "createdAt": "1970-01-01T00:00:00Z",
 4    "creatorId": "auser",
 5    "id": "t3",
 6    "name": "Topic 3",
 7    "updatedAt": "2023-08-12T06:13:03.488044Z",
 8    "users": {}
 9  }
10}

Now the listing:

1% oh topics list
 1{
 2  "nextPageKey": "",
 3  "topics": [
 4    {
 5      "createdAt": "1970-01-01T00:00:00Z",
 6      "creatorId": "auser",
 7      "id": "t1",
 8      "name": "Topic 1",
 9      "updatedAt": "2023-08-12T05:46:45.981803Z",
10      "users": {}
11    },
12    {
13      "createdAt": "1970-01-01T00:00:00Z",
14      "creatorId": "auser",
15      "id": "t2",
16      "name": "Topic 2",
17      "updatedAt": "2023-08-12T05:47:01.380518Z",
18      "users": {}
19    },
20    {
21      "createdAt": "1970-01-01T00:00:00Z",
22      "creatorId": "auser",
23      "id": "t3",
24      "name": "Topic 3",
25      "updatedAt": "2023-08-12T06:13:03.488044Z",
26      "users": {}
27    }
28  ]
29}

With the implementation of the rest of the topics, users and message cli commands, let us put our cli to the test:

Update a topic

1oh topics update t1 --name "Computing"
 1{
 2  "topic": {
 3    "createdAt": "1970-01-01T00:00:00Z",
 4    "creatorId": "auser",
 5    "id": "t1",
 6    "name": "Computing",
 7    "updatedAt": "2023-08-12T16:12:23.314694Z",
 8    "users": {}
 9  }
10}

Delete a topic

1oh topics delete t2
1{}

Send a message on a topic

Let us send 3 messages on a topic:

1oh msg send t1 "My first message"
2oh msg send t1 "My second message"
3oh msg send t1 'Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.'
 1{
 2  "message": {
 3    "contentData": null,
 4    "contentText": "My first message",
 5    "contentType": "text",
 6    "createdAt": "1970-01-01T00:00:00Z",
 7    "id": "hlaz",
 8    "topicId": "t1",
 9    "updatedAt": "2023-08-12T16:35:31.935017Z",
10    "userId": "auser"
11  }
12}
 1{
 2  "message": {
 3    "contentData": null,
 4    "contentText": "My second and longer message",
 5    "contentType": "text",
 6    "createdAt": "1970-01-01T00:00:00Z",
 7    "id": "apgz",
 8    "topicId": "t1",
 9    "updatedAt": "2023-08-12T16:41:35.827131Z",
10    "userId": "auser"
11  }
12}
 1{
 2  "message": {
 3    "contentData": null,
 4    "contentText": "Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of \"de Finibus Bonorum et Malorum\" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, \"Lorem ipsum dolor sit amet..\", comes from a line in section 1.10.32.",
 5    "contentType": "text",
 6    "createdAt": "1970-01-01T00:00:00Z",
 7    "id": "6y2q",
 8    "topicId": "t1",
 9    "updatedAt": "2023-08-12T16:41:46.878823Z",
10    "userId": "auser"
11  }
12}

Listing messages in a topic

1% oh msg list t1
 1{
 2  "messages": [
 3    {
 4      "contentData": null,
 5      "contentText": "My first message",
 6      "contentType": "text",
 7      "createdAt": "0001-01-01T00:00:00Z",
 8      "id": "hlaz",
 9      "topicId": "t1",
10      "updatedAt": "2023-08-12T16:35:31.935017Z",
11      "userId": "auser"
12    },
13    {
14      "contentData": null,
15      "contentText": "My second and longer message",
16      "contentType": "text",
17      "createdAt": "0001-01-01T00:00:00Z",
18      "id": "apgz",
19      "topicId": "t1",
20      "updatedAt": "2023-08-12T16:41:35.827131Z",
21      "userId": "auser"
22    },
23    {
24      "contentData": null,
25      "contentText": "Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of \"de Finibus Bonorum et Malorum\" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, \"Lorem ipsum dolor sit amet..\", comes from a line in section 1.10.32.",
26      "contentType": "text",
27      "createdAt": "0001-01-01T00:00:00Z",
28      "id": "6y2q",
29      "topicId": "t1",
30      "updatedAt": "2023-08-12T16:41:46.878823Z",
31      "userId": "auser"
32    }
33  ],
34  "nextPageKey": ""
35}

Deleting messages

1% oh msg delete hlaz apgz 6y2q
2% oh msg list t1
1{
2  "messages": [],
3  "nextPageKey": ""
4}

Conclusion

In this deep-dive we built a basic CLI for accessing our OneHub (chat) service with the popular Cobra framework. We will build upon this CLI in future posts for more advanced use cases (shell command integration, subscribing to streams etc).

In a future post we will look at a few more advance use cases:

  • Commands to “listen” to messages posted on a topic and connecting via websockets
  • Sending more custom messages to topics
  • Streaming shell command outputs to topics
  • Configuration management by integrating with Viper.
  • And more