Go Crash Course Pt. 2: A Real-World Scenario

May 21, 2020 13 min read

Go Crash Course Pt. 2: A Real-World Scenario

In the first part of our Go Crash Course, we set up our environment, made ourselves familiar with a few Go basics, and wrote our first Go application. Now it's time to take it a step further and create an application that resembles something you're more likely to encounter in real life.

A few parts of what's coming rely on HTTP and JSON, so make sure you're well familiar with them before diving deep into this. Otherwise, let's Go.

A Real-World Scenario

We're going to recreate our “Hello World!” application so it looks more like something actual Go developers would write in live projects.

Let's create a simple HTTP endpoint that returns our string as its response. Our main.go file should look something like this:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/hello", func(responseWriter http.ResponseWriter, request *http.Request) {
	responseWriter.Header().Set("Content-Type", "application-json")
	responseWriter.WriteHeader(http.StatusOK)
	responseWriter.Write([]byte(`{"message":"Hello World!"}`))
})

	fmt.Println("http server listening on localhost:8080")
	http.ListenAndServe(":8080", nil)
}

Okay, let's analyze this. We're creating an HTTP handler by binding a path named /hello to a function. The HTTP handler functions will always receive two parameters:

  • http.ResponseWriter to write a response back to who requested it.
  • *http.Request to understand the type of request we're dealing with. But, in this basic example, we're not using *http.Request for anything.

For the function's body, we first write Content-Type: application/json to the response's headers. Then we write out the header with a 200 HTTP status code. Keep in mind that you can't add anything to the headers once you've called the WriteHeader function.

Next, we're writing out our response body. The Write function receives a []byte. We're handwriting a JSON string and converting it to []byte so it can be used in the Write function.

Outside of the HTTP handler function, we're printing a message so we're aware that something happened. We also start our HTTP server, which will listen for requests on port :8080. http.ListenAndServe accepts an address and an HTTP handler as its parameters.

You might wonder, if the address is :8080, why are we passing nil as the handler? Because http.HandleFunc binds the handler to the default HTTP handler. When http.ListenAndServe doesn't receive any handlers, it uses that default HTTP handler. That's why everything works as expected 🙂

Or at least it should! Let's test it out. In your VSCode terminal, run the application:

go run main.go

You should see a message saying that the server is running. The prompt should be in "busy mode". To test this out, open your browser and type in localhost:8080/hello. You should see our message sent in response to the browser.

To stop the application, go back to the terminal and hold the control+c keys. This should return the terminal to idle mode.

Explaining a Home Device Controller

Time for something more ambitious. Let's create an API to control our house appliances. Wouldn't that be cool? Fundamentally, what we need to do here is deliver an HTTP service that can tell us the current status of our devices. We also want that service to change the status of a device whenever we want it to.

Let's start simple. Something to control our light bulbs. Create a simple map to hold the state of the bulbs in our house and assign a few standard bulbs to it.

package main

import (
	"fmt"
	"net/http"
)

var (
	lightbulbs = make(map[string]bool)
)

func main() {
	lightbulbs["livingroom"] = false
	lightbulbs["kitchen"] = false

	http.HandleFunc("/hello", func(responseWriter http.ResponseWriter, request *http.Request) {
	responseWriter.Header().Set("Content-Type", "application-json") 
	responseWriter.WriteHeader(http.StatusOK)
	responseWriter.Write([]byte(`{"message":"Hello World!"}`))
})

	fmt.Println("http server listening on localhost:8080")
	http.ListenAndServe(":8080", nil)
}

In our previous blog post, we said that maps can be used to index values by key. In this case, we're indexing light bulb states (on or off) by room name. Each bulb is turned off by default.

Now we want an endpoint to list these states. Let's create a simple endpoint that will output the current state of our bulb grid.

http.HandleFunc("/lightbulbs", func(reponseWriter http.ResponseWriter, request *http.Request) {
	responseWriter.Header().Set("Content-Type", "application-json")
	responseWriter.WriteHeader(http.StatusOK)
	json.NewEncoder(responseWriter).Encode(lightbulbs)
})

Just like in our Hello World example, this is a simple endpoint that binds an HTTP handle function to the path /lightbulbs. Whenever a request to that path is made, the function will run.

But there's a new element to this handle function. Since we don't want to handwrite our JSON structure, we're using a JSON encoder to write the JSON representation of our map to the ResponseWriter.

If we run our code and hit this path in the browser, we should be able to see both bulbs having the value of false (because they're turned off right now).

Now we need a way to change the statuses of our bulbs, right? So let's create a new endpoint to deal with that behavior too.

http.HandleFunc("/lightbulbs/switch", func(responseWriter http.ResponseWriter, request *http.Request) {
	responseWriter.Header().Set("Content-Type", "application-json")

	name := request.URL.Query().Get("name")
	if name == "" {
		responseWriter.WriteHeader(http.StatusBadRequest)
		responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
		return
	}

	if _, keyExists := lightbulbs[name]; !keyExists {
		responseWriter.WriteHeader(http.StatusNotFound)
		responseWriter.Write([]byte(`{"message":"a light bulb with the provided name doesn't exist"}`))
		return
	}

	lightbulbs[name] = !lightbulbs[name]

	responseWriter.WriteHeader(http.StatusOK)
	json.NewEncoder(responseWriter).Encode(lightbulbs)
})

In the above snippet of code, we set the content types of our future responses. We get a value from our querystring (key-value pairs appended to the URL after a ?) with the key "name".

If the querystring was empty or not present, we set our response status code to BadRequest to indicate the request didn't follow the rules we expected. We also added a handwritten JSON with a message that explained what happened. The keyword return ends the execution of the HTTP handler right there, as there's no point in processing the request without the info required for it.

If the name doesn't exist, we try to get a value from our map using that name as the key. If a value with that key doesn't exist, we set the response status code to NotFound and create a handwritten JSON message explaining that no bulb was found with that name.

If there is a key on the map with the given name, then we set its value to its opposite by using the ! operator. If the value is false, we set it to true and vice versa. We do the same with the status endpoint by setting the status code to OK and writing the JSON representation of our map to the response.

Now we need to create an endpoint to add new bulbs.

http.HandleFunc("/lightbulbs/create", func(responseWriter http.ResponseWriter, request *http.Request) {
	responseWriter.Header().Set("Content-Type", "application-json")
	
	name := request.URL.Query().Get("name")
	if name == ""
		responseWriter.WriteHeader(http.StatusBadRequest)
		responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
		return
	}

	if _, keyExists := lightbulbs[name]; keyExists {
		responseWriter.WriteHeader(http.StatusBadRequest)
		responseWriter.Write([]byte(`{"message":"a lightbulb with the provided name already exists"}`))
		return
	}

	lightbulbs[name] = false

	responseWriter.WriteHeader(http.StatusOK)
	json.NewEncoder(responseWriter).Encode(lightbulbs)
})

You'll notice that this is very similar to our previous endpoint. It checks for the presence of a name querystring parameter, except that we're now looking for a key that already exists on our map. We're not supposed to create something that already exists. If the key already exists, our response is a BadRequest with a message saying it already exists.

If everything goes as planned, we simply create a new entry for our map, adding a light bulb that's turned off with a given name. We respond with OK and the current content of the map.

Now that we're able to create new bulbs, we should be able to delete them as well. So let's create an endpoint for that too.

http.HandleFunc("/lightbulbs/delete", func(responseWriter http.ResponseWriter, request *http.Request) {
	responseWriter.Header().Set("Content-Type", "application-json")
	
	name := request.URL.Query().Get("name")
	if name == ""
		responseWriter.WriteHeader(http.StatusBadRequest)
		responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
		return
	}

	if _, keyExists := lightbulbs[name]; !keyExists {
		responseWriter.WriteHeader(http.StatusNotFound)
		responseWriter.Write([]byte(`{"message":"a lightbulb with the provided name doesn't exist"}`))
		return
	}

	delete(lightbulbs, name)

	responseWriter.WriteHeader(http.StatusOK)
	json.NewEncoder(responseWriter).Encode(lightbulbs)
})

The difference in this endpoint is the second validation, because we cannot delete something that doesn't exist. If everything goes to plan, we call delete to delete the key we want.

For future use, let's change our hello handler to a healthcheck handler. Just to make sure our service is running reliably.

http.HandleFunc("/healthcheck", func(responseWriter http.ResponseWriter, request *http.Request) {
	responseWriter.Header().Set("Content-Type", "application-json")
	responseWriter.WriteHeader(http.StatusOK)
	responseWriter.Write([]byte(`{"message":"service is up and running"}`))
})

Testing

In future parts of our Go Course, we're going to change this application to improve its readability and testability. Improving your codebase without changing its behavior is called refactoring.

In order to refactor, we need to make sure that our future changes don't break our current behavior. A good way to prevent that is by implementing tests that can be run whenever we want to, to ensure everything is working as expected.

Go tests are functions written like this.

func TextXxx(t *testing.T){}

Conventionally, the tool go test treats functions following the above template as tests.

Let's write our first test for the healthcheck endpoint. To do so, create a new file called healthcheck_test.go and use this as the file's content:

package main

import (
	"encoding/json"
	"net/http"
	"os"
	"testing"
)

func TestHealthCheck(t *testing.T) {
	go main()

	response, err := http.Get("http://localhost:8080/healthcheck")
	if err != nil {
		t.Errorf("expected no errors, but got %v", err)
	}

	if response.StatusCode != http.StatusOK {
		t.Errorf("expected 200 statuscode, but got %v", response.StatusCode)
	}

	responseBody := make(map[string]interface{})
	json.NewDecoder(response.Body).Decode(&responseBody)
	response.Body.Close()

	if responseBody["message"] != "service is up and running" {
		t.Errorf(`expected message to be "service is up and running", but got %v`, responseBody["message"])
	}

	os.Interrupt.Signal()
}

It's a simple, arguably even naive approach to testing, but it's okay for now. We'll refactor this too in the future.

Let's analyze the above code snippet. It's fairly straightforward, but there are a bunch of new things, so let's walk through them slowly.

First up is a new keyword, go. Remember when we ran our application and the terminal would sit in busy mode until we terminated the program? It's the same here. If we'd just run the main function, our test would be stuck, waiting until the test itself times out after thirty seconds.

The go keyword tells the program to run the provided function on a separate goroutine. We'll talk about goroutines further in the course. For now, let's just say it runs the provided function on a different workspace than the running function, so it doesn't block its execution. This way, we can run our main function and still collect our test results.

After running the main function, we're using the HTTP package to do a GET HTTP call to our service on the healthcheck endpoint. The result of the http.Get function is an HTTP response and an error. The first thing we do is check for the error. Then, we check if the response status code is what we expected. Finally, we check is the response body is what we expected.

Whenever anything isn't as we expect it to be, we call t.Errorf to fail the test with a friendly message that tells us what went wrong.

To write the JSON representation of a structure to the ResponseWriter, we used json.NewEncoder().Encode(). To flip this process around and grab a structure from a JSON-encoded response, we use json.NewDecoder().Decode().

After we've run all the checks, we fire an Interrupt signal to stop our program. Pretty much what control+c does on our terminal.

To run the test, simply type go test ./... in the terminal. The test will run and the output will be that all tests passed. In order to check if our test is really validating things, feel free to change the response status code from our healthcheck function to something other than http.StatusOK. Tests will fail if you do so.

Exercise: try to test other endpoints!


This was the second part of our Go Crash Course. In the third part, we'll test the other functions for every possible scenario and we'll get started with our refactoring. Until next time!

SHARE:

arrow_upward