mnrva.dev

Building a Stateless, Containerized Trivia API in Golang

Gabe Farrell - 09/12/2023

Follow along as I build a stateless trivia API in Go and containerize it with Docker.

While working on a much larger project, I decided it would be a good idea to make something smaller that could show off how much I have improved my development strategies and back end knowledge since the days of my first full stack project, Massflip. Today, I will be walking you through my process of creating a trivia API that will allow people to get random trivia questions and then check their answers for correctness. Since this is supposed to be a stateless system, there should be no writing to a database when getting trivia questions or verifying answers. In fact, we won’t have any database at all, other than however we choose to store our trivia questions (for this project, our questions will be stored in a json file that gets loaded into memory, but you could modify it to use an SQL database or similar).

Now since I don’t really know anything about trivia terminology, let’s go over the terms I have decided to use when referring to our objects.

An API is a Contract

An API is a contract that defines the interaction between a client and server. So to begin developing the API, we need to first define the terms of the contract we are creating. Our trivia server will have two endpoints:

The server must not store any question state in between request calls, so we will need to generate an ID that will be a reference to the question we are making a guess for. Because we need the ID to make a guess, we will also need to return that ID when we are getting the trivia question from the /trivia endpoint. Since we are already defining some specifics about request and response parameters, let’s go ahead and define the specification for our API.

MethodEndpointAcceptsRequestResponse
GET/triviaapplication/json
application/x-www-form-urlencoded
category: string (optional)application/json
question_id: string
question: string
category: string
format: MultipleChoice|TrueFalse
choices: [string]string IF format=MultipleChoice
GET/guessapplication/json
application/x-www-form-urlencoded
question_id: string
guess: string
application/json
question_id: string
correct: boolean

You may notice that this specification only includes the OK paths and doesn’t include any error responses. We will get to those later. Now that we have designed the contract that our API must follow, we can begin to enforce the contract using unit tests.

Bottom-Up Development

This project will follow a bottom-up development structure. In this structure, we will first develop the components (in Go, modules) of our application then combine them in higher layers to form our completed application. The structure of our program will look like this:

Main -> Server -> Trivia

top ------------> bottom

The trivia module will define the way we store and retrieve trivia questions, as well as any other “database” functions we may need, the server module will define our server structure and endpoint handlers, and the main module will tell our server to begin listening for http requests.

The Trivia Module

Just the same as we created a specification for our server as a whole, we need to also create a specification for the public functions in the modules we create. Creating this contract and enforcing it using tests is essential for ensuring our application is clearly structured and easily readable. This step is even more important when creating modules that you intend to reuse in other projects or to be publicly available.

Our Trivia Module will define a type Questions, which will hold all of our trivia questions in memory. This is essentially our database. On that type, we will define two publicly available methods:

Questions.GetRandomQuestion(string) -> Question and Questions.GetQuestionById(string) -> Question

We will enforce their behavior by writing tests. Let’s begin to finally write some code. First, we create our go module for the whole project, then create our submodule directory and files for our Questions type.

$ go mod init github.com/gabehf/trivia-api
$ mkdir trivia
$ touch trivia/questions.go trivia/questions_test.go

In order to write our Questions structure’s methods, we need to define the data that will be contained within it.

// questions.go
package trivia

import "sync"

// represents the structure of trivia questions stored
// in the trivia.json file
type Question struct {
	Question string   `json:"question"`
	Category string   `json:"category"`
	Format   string   `json:"format"`
	Choices  []string `json:"choices"`
	Answer   string   `json:"answer"`
}

type Questions struct {
	Categories []string
	M          map[string][]*Question
	lock       *sync.RWMutex
}

func (q *Questions) Init() {
	q.lock = &sync.RWMutex{}
}

// Gets a random question and its index from the category, if specified.
func (q *Questions) GetRandomQuestion(category string) (*Question, int) {
	return nil, 0
}

func (q *Questions) GetQuestionById(id string) *Question {
	return nil
}

You may notice here that I have chosen to include a sync.RWMutex as part of our Questions struct to ensure it is concurrency safe, even though concurrent reads from a map are already concurrency safe in Go. I included the mutex because in the future I plan to allow for reloading the trivia question list while the server is still running. Now that we have our database representation, we can fill in some test data and write the tests to enforce the contract of the trivia module’s API. We will add the test data in a TestMain for the trivia package.

$ touch trivia/trivia_main_test.go

// trivia_main_test.go
package trivia_test

var Q trivia.Questions
var expect *trivia.Question

func TestMain(m *testing.M) {
	Q.Init()
	Q.Categories = []string{"world history"}
	Q.M = map[string][]trivia.Question{
		"world history": {
			{
				Question: "The ancient city of Rome was built on how many hills?",
				Format:   "MultipleChoice",
				Category: "World History",
				Choices: []string{
					"Eight",
					"Four",
					"Nine",
					"Seven",
				},
				Answer: "Seven",
			},
		},
	}
expect = &trivia.Question{
		Question: "The ancient city of Rome was built on how many hills?",
		Category: "World History",
		Format:   "MultipleChoice",
		Choices: []string{
			"Eight",
			"Four",
			"Nine",
			"Seven",
		},
		Answer: "Seven",
	}
	m.Run()
}

// questions_test.go
package trivia_test

import (
	"reflect"
	"testing"

	"github.com/gabehf/trivia-api/trivia"
)

func TestGetRandomQuestion(t *testing.T) {
	// on OK path, GetTrivia must return the question in our test data
	tq, _ := Q.GetRandomQuestion("world history")
	if tq == nil {
		t.Fatal("trivia question must not be nil")
	}
	if !reflect.DeepEqual(tq, expect) {
		t.Errorf("returned question does not match expectation, got %v", tq)
	}

	// with no category specified, GetTrivia must pick a random category and fetch a question
	// with only one question in our test data, it is the same question from before
	tq, _ = Q.GetRandomQuestion("")
	if tq == nil {
		t.Fatal("trivia question must not be nil")
	}
	if !reflect.DeepEqual(tq, expect) {
		t.Errorf("returned question does not match expectation, got %v", tq)
	}

	// on FAIL path, GetTrivia must return nil to indicate no questions are found
	tq, _ = Q.GetRandomQuestion("Geography")
	if tq != nil {
		t.Errorf("expected nil, got %v", tq)
	}
}

func TestGetQuestionById(t *testing.T) {
	// on OK path, GetTrivia must return the question in our test data
	tq := Q.GetQuestionById("world History|0")
	if tq == nil {
		t.Fatal("trivia question must not be nil")
	}
	if !reflect.DeepEqual(tq, expect) {
		t.Errorf("returned question does not match expectation, got %v", tq)
	}

	// FAIL path: malformed id
	tq = Q.GetQuestionById("hey")
	if tq != nil {
		t.Errorf("expected nil, got %v", tq)
	}
	// FAIL path: invalid category
	tq = Q.GetQuestionById("hey|0")
	if tq != nil {
		t.Errorf("expected nil, got %v", tq)
	}
	// FAIL path: invalid index
	tq = Q.GetQuestionById("world history|9")
	if tq != nil {
		t.Errorf("expected nil, got %v", tq)
	}
}

If we run go test ./... you will see that our tests are working and reporting a lot of failures since we have not implemented our methods yet. So let’s go ahead and fill in the logic for those methods.

// questions.go
package trivia

import (
	"math/rand"
	"strconv"
	"strings"
	"sync"
)
...
func (q *Questions) categoryExists(cat string) bool {
	q.lock.RLock()
	defer q.lock.RUnlock()
	cat = strings.ToLower(cat)
	if q.M[cat] == nil || len(q.M[cat]) < 1 {
		return false
	}
	return true
}

func (q *Questions) getRandomCategory() string {
	q.lock.RLock()
	defer q.lock.RUnlock()
	return q.Categories[rand.Int()%len(q.Categories)]
}

// Gets a random question from the category, if specified.
func (q *Questions) GetRandomQuestion(category string) (*Question, int) {
	q.lock.RLock()
	defer q.lock.RUnlock()
	// NOTE: it is okay to call another function that locks the RWMutex here,
	// as it will not cause a deadlock since CategoryExists only locks the Read
	if category == "" {
		category = q.getRandomCategory()
	} else if !q.categoryExists(category) {
		return nil, 0
	}
	category = strings.ToLower(category)
	qIndex := rand.Int() % len(q.M[category])
	return &q.M[category][qIndex], qIndex
}

func (q *Questions) GetQuestionById(id string) *Question {
	// get values from question_id
	questionSlice := strings.Split(id, "|")
	if len(questionSlice) != 2 {
		return nil
	}
	category, indexS := questionSlice[0], questionSlice[1]
	category = strings.ToLower(category)
	index, err := strconv.Atoi(indexS)
	if err != nil {
		return nil
	}

	q.lock.RLock()
	defer q.lock.RUnlock()
	// ensure category exists
	if !q.categoryExists(category) {
		return nil
	}
	// ensure question index is valid
	if len(q.M[category]) <= index {
		return nil
	}

	// retrieve question
	return &q.M[category][index]
}

I’ve skipped some refactoring steps for the sake of brevity, but you will notice that along with our two public functions I’ve also included some helper functions to separate some of the reusable logic. Now, let’s go back and run our tests.

$ go test ./...
ok      github.com/gabehf/trivia-api/trivia     0.002s

The Server Module

The server module is the next higher level in our bottom-up design. This module will use the structure and methods from the trivia module in the server endpoints. A server architecture I like to use in my programs is creating a server structure that holds the router and database, with methods that serve as endpoint handlers. Let’s first create our server structure:

$ mkdir server
$ touch server/server.go

// server.go
package server

import (
	"github.com/gabehf/trivia-api/trivia"
	"github.com/labstack/echo/v4"
)

type Server struct {
	Q *trivia.Questions
}

func (s *Server) Init() {
	s.Q = new(trivia.Questions)
	s.Q.Init()
}

func Run() error {
	e := echo.New()
	return e.Start(":3000")
}

Go ahead and go get any packages you may need, such as labstack/echo. Now, we can declare our two handlers.

// get_trivia.go
package server

type GetTriviaResponse struct {
	QuestionId string            `json:"question_id"`
	Question   string            `json:"question"`
	Category   string            `json:"category"`
	Format     string            `json:"format"`
	Choices    map[string]string `json:"choices,omitempty"`
}

func (s *Server) GetTrivia(e echo.Context) error {
	return errors.New("not implemented")
}

// get_guess.go
package server

func (s *Server) GetGuess(e echo.Context) error {
	return errors.New("not implemented")
}

I prefer to put handlers into their own files with the naming convention <method>_<path>.go . I am using LabStack’s echo router in this application, so the function signature for our handlers is function(echo.Context) error . Now let’s define our tests for both of these handlers in their respective test files. I will be following echo’s method for creating handler tests.

To start testing our handlers, let’s create our TestMain for our server package.

// server_main_test.go
package server_test

import (
	"testing"

	"github.com/gabehf/trivia-api/server"
	"github.com/gabehf/trivia-api/trivia"
)

var S *server.Server

func TestMain(m *testing.M) {
	S = new(server.Server)
	S.Init()
	S.Q.Init()
	S.Q.Categories = []string{"world history"}
	S.Q.M = map[string][]trivia.Question{
		"world history": {
			{
				Question: "The ancient city of Rome was built on how many hills?",
				Format:   "MultipleChoice",
				Category: "World History",
				Choices: []string{
					"Eight",
					"Four",
					"Nine",
					"Seven",
				},
				Answer: "Seven",
			},
		},
	}
	m.Run()
}

And then we define our tests for each of our two handlers.

// get_trivia_test.go
package server_test

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/gabehf/trivia-api/server"
	"github.com/labstack/echo/v4"
)

func TestTriviaHandler(t *testing.T) {
	jsonBody := []byte("{\"category\":\"World History\"}")

	expect := server.GetTriviaResponse{
		Question: "The ancient city of Rome was built on how many hills?",
		Format:   "MultipleChoice",
		Category: "World History",
	}

	// OK path: json body
	e := echo.New()
	req := httptest.NewRequest("GET", "/trivia", bytes.NewReader(jsonBody))
	req.Header["Content-Type"] = []string{"application/json"}
	res := httptest.NewRecorder()
	c := e.NewContext(req, res)
	err := S.GetTrivia(c)
	if err != nil {
		t.Errorf("expected nil error, got %v", err)
	}
	if res.Code != http.StatusOK {
		t.Errorf("expected status 200 OK, got %d", res.Code)
	}

	result := new(server.GetTriviaResponse)
	err = json.Unmarshal(res.Body.Bytes(), result)
	if err != nil {
		t.Error("malformed json response")
	}
	if result.Question != expect.Question {
		t.Errorf("expected question '%s', got '%s'", expect.Question, result.Question)
	}
	if result.Format != expect.Format {
		t.Errorf("expected format %s, got %s", expect.Format, result.Format)
	}
	if !strings.EqualFold(expect.Category, result.Category) {
		t.Errorf("expected category %s, got %s", expect.Category, result.Category)
	}

	// OK path: urlencoded body
	e = echo.New()
	req = httptest.NewRequest("GET", "/trivia?category=World+History", nil)
	req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"}
	res = httptest.NewRecorder()
	c = e.NewContext(req, res)
	err = S.GetTrivia(c)
	if err != nil {
		t.Errorf("expected nil error, got %v", err)
	}
	if res.Code != http.StatusOK {
		t.Errorf("expected status 200 OK, got %d", res.Code)
	}
	expect = server.GetTriviaResponse{
		Question: "The ancient city of Rome was built on how many hills?",
		Format:   "MultipleChoice",
		Category: "World History",
	}
	result = new(server.GetTriviaResponse)
	err = json.Unmarshal(res.Body.Bytes(), result)
	if err != nil {
		t.Error("malformed json response")
	}
	if result.Question != expect.Question {
		t.Errorf("expected question '%s', got '%s'", expect.Question, result.Question)
	}
	if result.Format != expect.Format {
		t.Errorf("expected format %s, got %s", expect.Format, result.Format)
	}
	if !strings.EqualFold(expect.Category, result.Category) {
		t.Errorf("expected category %s, got %s", expect.Category, result.Category)
	}

	// OK path: no body (random category)
	e = echo.New()
	req = httptest.NewRequest("GET", "/trivia", nil)
	res = httptest.NewRecorder()
	c = e.NewContext(req, res)
	err = S.GetTrivia(c)
	if err != nil {
		t.Errorf("expected nil error, got %v", err)
	}
	if res.Code != http.StatusOK {
		t.Errorf("expected status 200 OK, got %d", res.Code)
	}
	expect = server.GetTriviaResponse{
		Question: "The ancient city of Rome was built on how many hills?",
		Format:   "MultipleChoice",
		Category: "World History",
	}
	result = new(server.GetTriviaResponse)
	err = json.Unmarshal(res.Body.Bytes(), result)
	if err != nil {
		t.Error("malformed json response")
	}
	if result.Question != expect.Question {
		t.Errorf("expected question '%s', got '%s'", expect.Question, result.Question)
	}
	if result.Format != expect.Format {
		t.Errorf("expected format %s, got %s", expect.Format, result.Format)
	}
	if !strings.EqualFold(expect.Category, result.Category) {
		t.Errorf("expected category %s, got %s", expect.Category, result.Category)
	}
}

// get_guess_test.go
package server_test

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gabehf/trivia-api/server"
	"github.com/labstack/echo/v4"
)

func TestGuessHandler(t *testing.T) {
	jsonBody := []byte("{\"question_id\":\"World History|0\",\"guess\":\"seven\"}")

	// OK path: json body
	e := echo.New()
	req := httptest.NewRequest("GET", "/guess", bytes.NewReader(jsonBody))
	req.Header["Content-Type"] = []string{"application/json"}
	res := httptest.NewRecorder()
	c := e.NewContext(req, res)
	err := S.GetGuess(c)
	if err != nil {
		t.Errorf("expected nil error, got %v", err)
	}
	if res.Code != http.StatusOK {
		t.Errorf("expected status 200 OK, got %d", res.Code)
	}

	result := new(server.GetGuessResponse)
	err = json.Unmarshal(res.Body.Bytes(), result)
	if err != nil {
		t.Error("malformed json response")
	}
	if result.QuestionId != "World History|0" {
		t.Errorf("expected question_id 'World History|0', got '%s'", result.QuestionId)
	}
	if result.Correct != true {
		t.Errorf("expected correct to be true, got false")
	}

	// OK path: urlencoded body
	e = echo.New()
	req = httptest.NewRequest("GET", "/guess?question_id=World+History%7C0&guess=Seven", nil)
	req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"}
	res = httptest.NewRecorder()
	c = e.NewContext(req, res)
	err = S.GetGuess(c)
	if err != nil {
		t.Errorf("expected nil error, got %v", err)
	}
	if res.Code != http.StatusOK {
		t.Errorf("expected status 200 OK, got %d", res.Code)
	}

	result = new(server.GetGuessResponse)
	err = json.Unmarshal(res.Body.Bytes(), result)
	if err != nil {
		t.Error("malformed json response")
	}
	if result.QuestionId != "World History|0" {
		t.Errorf("expected question_id 'World History|0', got '%s'", result.QuestionId)
	}
	if result.Correct != true {
		t.Errorf("expected correct to be true, got false")
	}
}

Similar to when we defined our applications API specification at the beginning of this post, we are only testing the OK paths. We will add the FAIL paths later when I go over how we will be handling errors in our endpoints. Once again, running go test ./... here will generate a ton of errors since we have yet to implement our handlers.

Now that we have our applications API contract enforced with tests, we can implement our handlers.

// get_trivia.go
package server

import (
	"math/rand"
	"strconv"

	"github.com/labstack/echo/v4"
)

type GetTriviaRequest struct {
	Category string `json:"category" query:"category"`
}
type GetTriviaResponse struct {
	QuestionId string            `json:"question_id"`
	Question   string            `json:"question"`
	Category   string            `json:"category"`
	Format     string            `json:"format"`
	Choices    map[string]string `json:"choices,omitempty"`
}

func (s *Server) GetTrivia(e echo.Context) error {
	req := new(GetTriviaRequest)
	e.Bind(req)

	question, qIndex := s.Q.GetRandomQuestion(req.Category)
	if question == nil {
		return errors.New("unhandled error")
	}
	// randomly order answer choices if the format is multiple choice
	if question.Format == "MultipleChoice" && question.Choices != nil {
		rand.Shuffle(len(question.Choices), func(i, j int) {
			question.Choices[i], question.Choices[j] = question.Choices[j], question.Choices[i]
		})
		// enforce that multiple choice questions must have four choices
		// if not, there must be an error in our data somewhere that we need
		// to fix
		if len(question.Choices) != 4 {
			return errors.New("unhandled error")
		}
	}

	// build and return response
	tq := new(GetTriviaResponse)
	tq.QuestionId = question.Category + "|" + strconv.Itoa(qIndex)
	tq.Category = question.Category
	tq.Format = question.Format
	tq.Question = question.Question
	if tq.Format == "MultipleChoice" {
		tq.Choices = map[string]string{
			"A": question.Choices[0],
			"B": question.Choices[1],
			"C": question.Choices[2],
			"D": question.Choices[3],
		}
	}
	return e.JSONPretty(200, tq, "  ")
}

// get_guess.go
package server

import (
	"strings"

	"github.com/labstack/echo/v4"
)

type GetGuessRequest struct {
	QuestionId string `json:"question_id" query:"question_id"`
	Guess      string `json:"guess" query:"guess"`
}
type GetGuessResponse struct {
	QuestionId string `json:"question_id"`
	Correct    bool   `json:"correct"`
}

func (s *Server) GetGuess(e echo.Context) error {
	req := new(GetGuessRequest)
	e.Bind(req)

	// ensure required parameters exist
	errs := make(map[string]string, 0)
	if req.Guess == "" {
		errs["guess"] = "required parameter missing"
	}
	if req.QuestionId == "" {
		errs["question_id"] = "required parameter missing"
	}
	if len(errs) > 0 {
		return errors.New("unhandled error")
	}

	question := s.Q.GetQuestionById(req.QuestionId)
	if question == nil {
		errs["question_id"] = "invalid or malformed"
		return errors.New("unhandled error")
	}

	// validate answer with case insensitive string compare
	correct := strings.EqualFold(question.Answer, req.Guess)

	return e.JSONPretty(200, &GetGuessResponse{req.QuestionId, correct}, "  ")
}

And let’s run our tests to make sure the OK path is working.

$ go test ./...
ok      github.com/gabehf/trivia-tmp/server     0.004s
ok      github.com/gabehf/trivia-tmp/trivia     (cached)

Handle Errors and Failures With jSend

Note: I would usually not wait until the end to handle errors in my API, but for the sake of clarity in organization in this post I have formatted it this way.

When our handlers encounter an error - both from client error and server error - we need to inform the client so they can respond adequately. The format we will use to respond with errors is the jSend specification as defined in this GitHub post. Based on this specification, we can add FAIL paths(s) to our handler tests.

// get_trivia_test.go
...
func TestTriviaHandler(t *testing.T) {
	...
	// FAIL path: invalid category
	e = echo.New()
	req = httptest.NewRequest("GET", "/trivia?category=70s+Music", nil)
	req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"}
	res = httptest.NewRecorder()
	c = e.NewContext(req, res)
	err = S.GetTrivia(c)
	if err != nil {
		t.Errorf("expected nil error, got %v", err)
	}
	if res.Code != http.StatusNotFound {
		t.Errorf("expected status 404 Not Found, got %d", res.Code)
	}
	errResult := struct {
		Error bool
		Data  map[string]string
	}{}
	err = json.Unmarshal(res.Body.Bytes(), &errResult)
	if err != nil {
		t.Error("malformed json response")
	}
	if !errResult.Error {
		t.Error("expected error to be true, got false")
	}
	if errResult.Data["category"] == "" {
		t.Errorf("expected error information in data[category], got \"\"")
	}
}

// get_guess_test.go
...
func TestGuessHandler(t *testing.T) {
	...
	// FAIL path: invalid question id
	e = echo.New()
	req = httptest.NewRequest("GET", "/guess?question_id=hey&guess=Seven", nil)
	req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"}
	res = httptest.NewRecorder()
	c = e.NewContext(req, res)
	err = S.GetGuess(c)
	if err != nil {
		t.Errorf("expected nil error, got %v", err)
	}
	if res.Code != http.StatusNotFound {
		t.Errorf("expected status 400 Bad Request, got %d", res.Code)
	}
	errResult := struct {
		Error bool
		Data  map[string]string
	}{}
	err = json.Unmarshal(res.Body.Bytes(), &errResult)
	if err != nil {
		t.Error("malformed json response")
	}
	if !errResult.Error {
		t.Error("expected error to be true, got false")
	}
	if errResult.Data["question_id"] == "" {
		t.Errorf("expected error information in data[question_id], got \"\"")
	}

	// FAIL path: missing params
	e = echo.New()
	req = httptest.NewRequest("GET", "/guess", nil)
	req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"}
	res = httptest.NewRecorder()
	c = e.NewContext(req, res)
	err = S.GetGuess(c)
	if err != nil {
		t.Errorf("expected nil error, got %v", err)
	}
	if res.Code != http.StatusBadRequest {
		t.Errorf("expected status 400 Bad Request, got %d", res.Code)
	}
	errResult = struct {
		Error bool
		Data  map[string]string
	}{}
	err = json.Unmarshal(res.Body.Bytes(), &errResult)
	if err != nil {
		t.Error("malformed json response")
	}
	if !errResult.Error {
		t.Error("expected error to be true, got false")
	}
	if errResult.Data["question_id"] == "" {
		t.Errorf("expected error information in data[question_id], got \"\"")
	}
	if errResult.Data["guess"] == "" {
		t.Errorf("expected error information in data[guess], got \"\"")
	}
}

Running go test ./... now will show us just how many errors can go unnoticed without proper error handling and enforcement of fail behavior in our API.

$ go test ./...
--- FAIL: TestGuessHandler (0.00s)
    get_guess_test.go:78: expected nil error, got unhandled error
    get_guess_test.go:81: expected status 400 Bad Request, got 200
    get_guess_test.go:89: malformed json response
    get_guess_test.go:92: expected error to be true, got false
    get_guess_test.go:95: expected error information in data[question_id], got ""
    get_guess_test.go:106: expected nil error, got unhandled error
    get_guess_test.go:109: expected status 400 Bad Request, got 200
    get_guess_test.go:117: malformed json response
    get_guess_test.go:120: expected error to be true, got false
    get_guess_test.go:123: expected error information in data[question_id], got ""
    get_guess_test.go:126: expected error information in data[guess], got ""
--- FAIL: TestTriviaHandler (0.00s)
    get_trivia_test.go:127: expected nil error, got unhandled error
    get_trivia_test.go:130: expected status 404 Not Found, got 200
    get_trivia_test.go:138: malformed json response
    get_trivia_test.go:141: expected error to be true, got false
    get_trivia_test.go:144: expected error information in data[category], got ""
FAIL
FAIL    github.com/gabehf/trivia-tmp/server     0.003s
ok      github.com/gabehf/trivia-tmp/trivia     (cached)
FAIL

Now with the behavior enforced, we can extend our handlers to respond to errors correctly.

// get_trivia.go
...
type ErrorResponse struct {
	Error   bool              `json:"error"`
	Data    map[string]string `json:"data,omitempty"`
	Message string            `json:"message,omitempty"`
}
...
func (s *Server) GetTrivia(e echo.Context) error {
	req := new(GetTriviaRequest)
	e.Bind(req)

	question, qIndex := s.Q.GetRandomQuestion(req.Category)
	if question == nil {
		return e.JSON(404, &ErrorResponse{
			Error: true,
			Data: map[string]string{
				"category": "category is invalid",
			},
		})
	}
	// randomly order answer choices if the format is multiple choice
	if question.Format == "MultipleChoice" && question.Choices != nil {
		rand.Shuffle(len(question.Choices), func(i, j int) {
			question.Choices[i], question.Choices[j] = question.Choices[j], question.Choices[i]
		})
		// enforce that multiple choice questions must have four choices
		// if not, there must be an error in our data somewhere that we need
		// to fix
		if len(question.Choices) != 4 {
			return e.JSON(500, &ErrorResponse{
				Error:   true,
				Message: "internal server error",
			})
		}
	}

	// build and return response
	tq := new(GetTriviaResponse)
	tq.QuestionId = question.Category + "|" + strconv.Itoa(qIndex)
	tq.Category = question.Category
	tq.Format = question.Format
	tq.Question = question.Question
	if tq.Format == "MultipleChoice" {
		tq.Choices = map[string]string{
			"A": question.Choices[0],
			"B": question.Choices[1],
			"C": question.Choices[2],
			"D": question.Choices[3],
		}
	}
	return e.JSONPretty(200, tq, "  ")
}

// get_guess.go
...
func (s *Server) GetGuess(e echo.Context) error {
	req := new(GetGuessRequest)
	e.Bind(req)

	// ensure required parameters exist
	errs := make(map[string]string, 0)
	if req.Guess == "" {
		errs["guess"] = "required parameter missing"
	}
	if req.QuestionId == "" {
		errs["question_id"] = "required parameter missing"
	}
	if len(errs) > 0 {
		return e.JSON(400, &ErrorResponse{
			Error: true,
			Data:  errs,
		})
	}

	question := s.Q.GetQuestionById(req.QuestionId)
	if question == nil {
		errs["question_id"] = "invalid or malformed"
		return e.JSON(404, &ErrorResponse{
			Error: true,
			Data:  errs,
		})
	}

	// validate answer with case insensitive string compare
	correct := strings.EqualFold(question.Answer, req.Guess)

	return e.JSONPretty(200, &GetGuessResponse{req.QuestionId, correct}, "  ")
}

And verify that our tests are passing.

$ go test ./...
ok      github.com/gabehf/trivia-tmp/server     0.006s
ok      github.com/gabehf/trivia-tmp/trivia     (cached)

Loading Trivia Questions

Now that our handlers and Questions structure are working, and we can be sure that they are all working thanks to our tests, we can move onto the final (for now) step in our lower level modules and create the logic for loading trivia questions into our application. The questions will be stored in a file called trivia.json with a structure that mirrors the map M in our Questions structure.

// trivia.json
{
  "category": [
    {
			"category": STRING,
      "question": STRING,
      "answer": STRING,
      "format": "MultipleChoice"|"TrueFalse",   
    },
    {
      ...
    }
  ],
  "category 2": [
    ...
  ]
}

You can find a trivia.json file with questions already filled in at https://GitHub.com/gabehf/trivia-api.

With our trivia file in place, we can make the method of our Questions struct that handles loading in JSON data. To make sure our function is testable, the method will take an io.Reader as an argument.

// questions.go
...
func (q *Questions) Load(r io.Reader) error {
	q.lock.Lock()
	defer q.lock.Unlock()
	if q.M == nil {
		q.M = make(map[string][]Question, 0)
	}
	err := json.NewDecoder(r).Decode(&q.M)
	if err != nil {
		return err
	}
	if q.Categories == nil {
		q.Categories = make([]string, 0)
	}
	for key := range q.M {
		q.Categories = append(q.Categories, key)
	}
	return nil
}

And we can write a test to make sure our method works as expected using sample JSON data.

// questions_test.go
...
func TestLoad(t *testing.T) {
	json := []byte(`
		{
			"world history": [
				{
					"category": "World History",
					"question": "How many years did the 100 years war last?",
					"answer": "116",
					"format": "MultipleChoice",
					"choices": [
						"116",
						"87",
						"12",
						"205"
					]
				},
				{
					"category": "World History",
					"question": "True or False: John Wilkes Booth assassinated Abraham Lincoln.",
					"answer": "True",
					"format": "TrueFalse"
				}
			],
			"geography": [
				{
					"category": "Geography",
					"question": "What is the capital city of Japan?",
					"answer": "Tokyo",
					"format": "MultipleChoice",
					"choices": [
						"Beijing",
						"Seoul",
						"Bangkok",
						"Tokyo"
					]
				},
				{
					"category": "Geography",
					"question": "True or False: The Amazon Rainforest is located in Africa.",
					"answer": "False",
					"format": "TrueFalse"
				}
			]
		}
	`)
	expectCategories := []string{"world history", "geography"}
	expectQuestions := map[string][]trivia.Question{
		"world history": {
			{
				Question: "How many years did the 100 years war last?",
				Format:   "MultipleChoice",
				Answer:   "116",
				Category: "World History",
				Choices: []string{
					"116",
					"87",
					"12",
					"205",
				},
			},
			{
				Question: "True or False: John Wilkes Booth assassinated Abraham Lincoln.",
				Format:   "TrueFalse",
				Answer:   "True",
				Category: "World History",
			},
		},
		"geography": {
			{
				Question: "What is the capital city of Japan?",
				Format:   "MultipleChoice",
				Answer:   "Tokyo",
				Category: "Geography",
				Choices:  []string{"Beijing", "Seoul", "Bangkok", "Tokyo"},
			},
			{
				Question: "True or False: The Amazon Rainforest is located in Africa.",
				Format:   "TrueFalse",
				Answer:   "False",
				Category: "Geography",
			},
		},
	}
	qq := new(trivia.Questions)
	qq.Init()
	err := qq.Load(bytes.NewReader(json))
	if err != nil {
		t.Errorf("expected error to be nil, got %v", err)
	}
	for _, cat := range expectCategories {
		if !slices.Contains(qq.Categories, cat) {
			t.Errorf("expected category %s not present", cat)
		}
	}
	if !reflect.DeepEqual(qq.M, expectQuestions) {
		t.Errorf("unexpected question map, got %v", qq.M)
	}
}

Now we can run our test and… voila! It passes! Now all that is left is updating our server’s Run() function to load in our JSON data and mount our handlers, and create a main function to start the server. Let’s get those out of the way.

// server.go
...
func Run() error {
	// init server struct
	s := new(Server)
	s.Init()

	// load trivia data
	file, err := os.Open("trivia.json")
	if err != nil {
		panic(err)
	}
	err = s.Q.Load(file)
	if err != nil {
		panic(err)
	}

	// create router and mount handlers
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.GET("/trivia", s.GetTrivia)
	e.GET("/guess", s.GetGuess)

	// start listening
	return e.Start(":3000")
}

// main.go
package main

import (
	"log"

	"github.com/gabehf/trivia-api/server"
)

func main() {
	log.Println("Trivia API listening on http://127.0.0.1:3000")
	log.Fatal(server.Run())
}

At last our trivia is complete. Let’s run our application and make some requests.

$ go run .  
2023/12/09 05:10:39 Trivia API listening on http://127.0.0.1:3000

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.11.3
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
http server started on [::]:3000

$ curl 127.0.0.1:3000/trivia
{
  "question_id": "World History|2",
  "question": "Which world leader is famous for his "Little Red Book"?",
  "category": "World History",
  "format": "MultipleChoice",
  "choices": {
    "A": "Ho Chi Minh",
    "B": "Kim Jong-Un",
    "C": "Xi Xinping",
    "D": "Mao Zedong"
  }
}

Let’s see… I think that this was Mao Zedong. Let’s check my answer.

$ curl '127.0.0.1:3000/guess?question_id=World+History%7C2&guess=Mao+Zedong'
{
  "question_id": "World History|2",
  "correct": true
}

There we go! Our trivia API works! Now the only thing left to do in order to satisfy this post’s title is to containerize it. Let’s add a Dockerfile to our project root.

## syntax=docker/dockerfile:1
FROM golang:1.21
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
COPY ./server/*.go ./server/
COPY ./trivia/*.go ./trivia/
COPY trivia.json ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /TriviaAPI
CMD ["/TriviaAPI"]

Then, we can build our docker image and run it.

$ docker build --tag trivia-api .
[+] Building 57.4s (16/16) FINISHED
...
$ docker run -p 3000:3000 trivia-api
2023/12/09 05:50:06 Trivia API listening on http://127.0.0.1:3000

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.11.3
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
http server started on [::]:3000

And let’s test out our containerized trivia server.

$ curl 127.0.0.1:3000/trivia
{
  "question_id": "Art|6",
  "question": "Vincent Van Gogh is considered by many to be Post-Impressionist, or \"The Father of ________\".",
  "category": "Art",
  "format": "MultipleChoice",
  "choices": {
    "A": "Expressionism",
    "B": "Impressionism",
    "C": "Post-Modernism",
    "D": "Modernism"
  }
}

Perfect! Now we have a functional, stateless, containerized trivia API with robust unit testing. Now I wouldn’t call my code here perfect, not even close. We lack tiered logging, environment variables for the port and JSON file, a method to hot-reload our trivia questions, rate limiting, we could also add more tests to make sure every edge case is covered, etc. However, for the purposes of this (already long) blog post, we can call this complete. Maybe in the future I will take this baseline and turn it into what I would call a truly production-ready API.

I hope you enjoyed reading and following along with my programming process, and hopefully if you were a beginner at Go or back end development you were able to learn a little bit from this post. If I made a mistake somewhere, there isn’t really a way to reach out to me other than my email so please send me an email if I made a truly egregious error. Thank you for reading!