DEV Community

Jordan Gregory
Jordan Gregory

Posted on • Updated on

Writing a Go CLI to complete the DigitalOcean Functions Challenge

The Challenge

DigitalOcean released their version of serverless functions and released a challenge recently to showcase them as well. Being one that doesn't like to turn down a challenge, I took DigitalOcean up on their challenge with a Go CLI.

Getting started

First, it was pretty simple to read the challenge on the challenge website, but it left a few questions to be answered.

  1. The Content-Type and Accepted headers tell me that this application accepts and sends back JSON data, but the directions in the overall documentation don't really say that other than that a parameters file is a JSON document, but I went ahead and wrote my CLI as thought the API did accept and send back JSON. I was correct, but the documentation here wasn't entirely clear.

  2. Once you have deployed you "Sammy" (or your DigitalOcean mascot). You have to have a keen eye to catch it. If you don't believe it's there, you can easily inspect the page elements for something that looks like the Sammy that you created.

The Code

In this case, as this was just a throw away app, I just wrote everything into a main.go file and turns on modules with go mod init <github.com/your_handle/project_name>.

The first thing to do was to start with a CLI that would accept the name and type parameters as described in the instructions. I opted to put these flags in the init function and load a few module variables so I didn't really have to do much with them and I expected them to run each time the app was run.

// main.go

package main

import (
    "flag"
)

var (
    sammyname string
    sammytype string
)

const (
    API_URL = "https://functionschallenge.digitalocean.com/api/sammy"
)

func init() {
    flag.StringVar(&sammyname, "name", "", "The name to give your new Sammy.")
    flag.StringVar(&sammytype, "type", "", "The type to assign to your new Sammy.")
    flag.Parse()
}

func main() {}
Enter fullscreen mode Exit fullscreen mode

Running our app with go run main.go now gives us our command line flags.

After looking again at the docs, the Sammy Type parameter is really a enum, so I created a type and a few helper functions and used them to set the variable appropriately.

// main.go
...

import (
    "fmt"
    "log"
    "flag"
    "strings"
)

var (
    sammyname string
    sammytype sammyType
)

type (
    sammyType string
)

const (
    API_URL = "https://functionschallenge.digitalocean.com/api/sammy"

    sammyType_Sammy sammyType = "sammy"
    sammyType_Punk sammyType = "punk"
    sammyType_Dinosaur sammyType = "dinosaur"
    sammyType_Retro sammyType = "retro"
    sammyType_Pizza sammyType = "pizza"
    sammyType_Robot sammyType = "robot"
    sammyType_Pony sammyType = "pony"
    sammyType_Bootcamp sammyType = "bootcamp"
    sammyType_XRay sammyType = "xray"
)

func NewSammyType(s string) (sammyType, error) {
    var t sammyType
    switch strings.ToLower(s) {
    case "sammy":
        t = sammyType_Sammy
    case "punk":
        t = sammyType_Punk
    case "dinosaur":
        t = sammyType_Dinosaur
    case "retro":
        t = sammyType_Retro
    case "pizza":
        t = sammyType_Pizza
    case "robot":
        t = sammyType_Robot
    case "pony":
        t = sammyType_Pony
    case "bootcamp":
        t = sammyType_Bootcamp
    case "xray":
        t = sammyType_XRay
    default:
        return "", fmt.Errorf("%s is an invalid sammyType", s)
    }
    return t, nil
}

func (s sammyType) String() string {
    return string(s)
}

func init() {
    flag.StringVar(&sammyname, "name", "", "The name to give your new Sammy.")
    st := flag.String("type", "", "The type to assign to your new Sammy.")
    flag.Parse()

    sammyType, err := NewSammyType(*st)
    if err != nil {
        log.Fatal(err)
    }
}

...
Enter fullscreen mode Exit fullscreen mode

Now, we had something that is type correct for the API.
Next, I simply wrote in a struct to represent the request, as well as a few helper functions there as well.

// main.go
...

import (
    "fmt"
    "log"
    "flag"
    "strings"
    "bytes"
    "encoding/json"
    "io"
    "io/ioutil"
)

...

type (
    sammyType string

    sharksRequest struct {
        Name string `json:"name"`
        Type string `json:"name"`
    }
)

// func NewSharksRequest() is something that I normally write, but there was really no point in this case.

func (req *sharksRequest) setName(name string) *sharksRequest  {
    req.Name = name
}

func (req *sharksRequest) setType(t sammyType) *sharksRequest {
    req.Type = t.String()
}

func (req *sharksRequest) marshalJSON() ([]byte, error) {
    return json.Marshal(req)
} 

...
Enter fullscreen mode Exit fullscreen mode

Now we had something to send to the API. So it was just a matter of sending a request, and seeing what the output was as the output wasn't documented at all (that I could see).

// main.go
...

func main() {
    // Set some variables to use:
    // Mostly we need the set up the base request struct and
    // the http client.
    var (
        c := http.DefaultClient
        r := &sharksRequest{
            Name: sammyname,
        }
    )

    // We can use our helper function to make sure we are
    // getting correct values here.
    r.setType(NewSammyType(sammytype))

    // marshal the struct to JSON
    rb, err := r.marshalJSON()
    if err != nil {
        log.Fatal(err)
    }

    // Build the new http request
    req, err := http.NewRequest(http.MethodPost, API_URL, bytes.NewBuffer(rb))
    if err != nil {
        log.Fatal(err)
    }

    // Set the required headers (from the docs).
    req.Header = map[string][]string{
        "Accept": []string{"application/json"},
        "Content-Type": []string{"application/json"},
    }

    // Send the request
    resp, err := c.Do(req)
    if err != nil {
        log.Fatal(err)
    }

    // Because we don't know the structure of the output
    // we can just output the response to the terminal.
    defer resp.Body.Close()

    out, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Fatal(err)
    }
    log.Println(string(out))
}
Enter fullscreen mode Exit fullscreen mode

Running our code via go run, go run main.go -name <myname> -type <mytype> actually works at this point, but I don't like the raw byte string that we are outputting. In a real API handler, we want to put the response into a struct, so now that I can see some output, I can build a struct.

The output for me looked something like this for a success:

{"message":"Shark created successfully! Congrats on successfully completing the functions challenge!"}

and this for a failure:

{"message":"The name has already been taken.","errors":{"name":["The name has already been taken."]}}

So, with this info in mind, I wrote another struct and some more helper methods to load that info into:

// main.go
...

type (
    sammyType string

    sharksRequest struct {
        Name string `json:"name"`
        Type string `json:"name"`
    }

    sharksResponse struct {
        // Message seems pretty strait forward and exists 
        // in both the responses, so this is a pretty safe
        // bet
        Message string `json:"message"`
        // Errors on the other hand, I don't have enough
        // info to strongly type the response, but the
        // map type here works for now. I can change it
        // later if there is more I want to do with the
        // errors.
        Errors map[string][]string `json:"errors"`
    }

    // func NewSharksResponse() is something again that I would normally write, but it isn't necessary in this example

    func (resp *sharksResponse) unmarshalJSON(body io.ReadCloser) error {
        b, err := ioutil.ReadAll(body)
        if err != nil {
            return fmt.Errorf("unable to read http body: %w", err)
        }

        return json.Unmarshal(b, resp)
    }
)

...
Enter fullscreen mode Exit fullscreen mode

Now that we have this, we can add it to our main function:

// main.go
...

func main() {
    // Set some variables to use:
    // Mostly we need the set up the base request struct, 
    // the response struct, and the http client.
    var (
        c := http.DefaultClient
        r := &sharksRequest{
            Name: sammyname,
        }
        R := &sharksResponse{}
    )

    // We can use our helper function to make sure we are
    // getting correct values here.
    r.setType(NewSammyType(sammytype))

    // marshal the struct to JSON
    rb, err := r.marshalJSON()
    if err != nil {
        log.Fatal(err)
    }

    // Build the new http request
    req, err := http.NewRequest(http.MethodPost, API_URL, bytes.NewBuffer(rb))
    if err != nil {
        log.Fatal(err)
    }

    // Set the required headers (from the docs).
    req.Header = map[string][]string{
        "Accept": []string{"application/json"},
        "Content-Type": []string{"application/json"},
    }

    // Send the request
    resp, err := c.Do(req)
    if err != nil {
        log.Fatal(err)
    }

    // We have a structure to unmarshal to now, so lets use
    // it.
    defer resp.Body.Close()

    if err := R.unmarshalJSON(resp.Body); err != nil {
        log.Fatal(err)
    }

    // Now we can send some better info to the terminal
    if len(R.Errors) > 0 {
        log.Fatalf("An error occurred. Message: %s, Errors: %v", R.Message, R.Errors)
    }

    log.Println(R.Message)
}

Enter fullscreen mode Exit fullscreen mode

And with that, I would say we have a successful application. To see the full code, feel free to check out my repository for the challenge here: https://github.com/j4ng5y/digitalocean-functions-challenge

I will put more languages in this repo too, so follow the instructions in the README to look at other examples (as I create them). I will have similar write-ups for those other languages as well.

Discussion (0)