Building CLI Tools with Go (Golang): A JSON File Formatter
Tags: go, cli, devops, json
Published: 2024-03-20 10:36:14

## Introduction

Go is a simple language, it supports concurrency out of the box and compiles to executable files so users do not need to have Go installed to run our apps, this makes Go ideally suited for building CLI tools. In this article, we will be going over how to build a CLI utility to format JSON files in Go.

## Prerequisites

- A working Go installation. You can find instructions [here](https://go.dev/doc/install) to set up Go if you do not.
- A code editor (VsCode is a popular choice).
- This article assumes you have basic knowledge of programming concepts and the CLI.

## Getting ready

To confirm you have Go installed and ready to use run

```go
go version
```
You should get a response like

```text
go version go1.22.1 linux/amd64
```

If you did not, please follow the instructions [here](https://go.dev/doc/install) to install Go. 

With that out of the way here is the flow of our tool:

```go
// 1. Get the path for our input file from 
//    the command line arguments
// 2. Check if the input file is readable and contains 
//    valid JSON
// 3. Format the contents of the file
// 4. Save formatted JSON to another file
// 5. Compile our binary
```

### Step 0: 
In a folder of your choosing, create a main.go file using your code editor. Here are the contents of the file:

```go
// 1. Get the path for our input file from 
//    the command line arguments
// 2. Check if the input file is readable and contains 
//    valid JSON
// 3. Format the contents of the file
// 4. Save formatted JSON to another file
// 5. Compile our binary

package main

import "fmt"

func main() {
    fmt.Println("Hello, 你好")
}
```

To execute the following code in your CLI run

```bash
go run main.go
```

Which would give us the output

```text
Hello, 你好
```

### Step 1: 

First, we check that we have at least 1 argument passed to our program; the input file. To do this we would need to import the OS module

```go
import (
    "fmt"
    "os"
)
```

We check that at least 1 argument was passed and display an error otherwise:

```go
// Get the arguments passed to our program
arguments := os.Args[1:]

// Check that at least 1 argument was passed
if len(arguments) < 1 {
	// Display error message and exit the program
	fmt.Println("Missing required arguments")
	fmt.Println("Usage: go run main.go input_file.json")
	return
}
```

### Step 2

Next, we confirm that our input file is readable and contains valid JSON

```go
// We add the encoding/json, errors and bytes module
import (
    "bytes"
    "encoding/json"
    "errors"
    ...
)

// A function to check if a string is valid JSON
// src: https://stackoverflow.com/a/22129435
func isJSON(s string) bool {
    var js map[string]interface{}
    return json.Unmarshal([]byte(s), &js) == nil
}

// We define a function to check if the file exists
func FileExists(name string) (bool, error) {
    _, err := os.Stat(name)
    if err == nil {
        return true, nil
    }
    if errors.Is(err, os.ErrNotExist) {
        return false, nil
    }
    return false, err
}

// A function to pretty print JSON strings
// src: https://stackoverflow.com/a/36544455 (modified)
func jsonPrettyPrint(in string) string {
    var out bytes.Buffer
    err := json.Indent(&out, []byte(in), "", "    ")
    if err != nil {
        return in
    }
    return out.String()
}


// In our main function, we check if the file exists
func main() {
....
    // Call FileExists function with the file path
    exists, err := FileExists(arguments[0])

    // Check the result
    if exists != true {
        fmt.Println("Sorry the file", arguments[0], "does not exist!")
    return
    }
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    raw, err := os.ReadFile(arguments[0]) // just pass the file name
    if err != nil {
        fmt.Print(err)
    }
    
    // convert the files contents to string
    contents := string(raw)

    // check if the string is valid json
    if contents == "" || isJSON(contents) != true {
        fmt.Println("Invalid or empty JSON file")
        return
    }
}
```

### Step 3

We format the contents of the file and display it

```go
    // print the formatted string; this output can be piped to an output file
    // no prefix and indenting with 4 spaces
    formatted := jsonPrettyPrint(contents)

    // Display the formatted string
    fmt.Println(formatted)
```

### Step 4

Save formatted JSON to another file, and to do that we pipe the output to a file

```bash
go run main.go input.json > out.json
```

The angle brackets `>`  redirects the output of our program to a file `out.json`

### Step 5

Compile our code to a single binary. To do this we run

```bash
# Build our code
go build main.go

# list the contents of the current directory
# we would have an executable binary called "main"
ls
# main main.go

#We will rename our executable prettyJson
mv main prettyJson
```

We can run our new binary as we would any other executable

Let's update our usage instructions

```go
// fmt.Println("Usage: go run main.go input_file.json")
// becomes
fmt.Println("Usage: ./prettyJson input_file.json")
```

Here is the completed code
```go
// 1. Get the path for our input file from
//    the command line arguments
// 2. Check if the input file is readable and contains
//    valid JSON
// 3. Format the contents of the file
// 4. Save formatted JSON to another file
// 5. Compile our binary

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "os"
)

// A function to check if a string is valid JSON
// src: https://stackoverflow.com/a/22129435 (modified)
func isJSON(s string) bool {
    var js map[string]interface{}
    return json.Unmarshal([]byte(s), &js) == nil

}

func FileExists(name string) (bool, error) {
    _, err := os.Stat(name)
    if err == nil {
        return true, nil
    }
    if errors.Is(err, os.ErrNotExist) {
        return false, nil
    }
    return false, err
}

// A function to pretty print JSON strings
// src: https://stackoverflow.com/a/36544455
func jsonPrettyPrint(in string) string {
    var out bytes.Buffer
    err := json.Indent(&out, []byte(in), "", "\t")
    if err != nil {
        return in
    }
    return out.String()
}

func main() {
    // Get the arguments passed to our program
    arguments := os.Args[1:]

    // Check that at least 1 argument was passed
    if len(arguments) < 1 {
        // Display error message and exit the program
        fmt.Println("Missing required argument")
        fmt.Println("Usage: ./prettyJson input_file.json")
        return
    }

    // Call FileExists function with the file path
    exists, err := FileExists(arguments[0])

    // Check the result
    if exists != true {
        fmt.Println("Sorry the file", arguments[0], "does not exist!")
        return
    }
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    raw, err := os.ReadFile(arguments[0]) // just pass the file name
    if err != nil {
        fmt.Print(err)
    }

    // convert the files contents to string
    contents := string(raw)

    // check if the string is valid json
    if contents == "" || isJSON(contents) != true {
        fmt.Println("Invalid or empty JSON file")
        return
    }

    // print the formatted string; this output can be piped to an output file
    // no prefix and indenting with 4 spaces
    formatted := jsonPrettyPrint(contents)

    // Display the formatted string
    fmt.Println(formatted)
}

```

## Conclusion

We have seen how to create a JSON formatter utility with Go. The code is available as a GitHub gist [here](https://gist.github.com/RussellJapheth/5c317783490afb83583bcc67c7572cac). Feel free to make improvements.