This is the first post in a series about writing REST servers in Go. My plan with this series is to implement a simple REST server using several different approaches, which should make it easy to compare and contrast these approaches and their relative merits.

Here is a list of posts in the series:

Developers who just start using a language often ask "what framework should I use to do X" as one of their first questions. While this makes total sense for web applications and servers in many languages, in Go the answer to this question is nuanced. There are strong opinions both for and against using frameworks. My goal in these posts is to examine the issue objectively from several angles.

Update (2024-03-06): this series was updated with the new stdlib HTTP mux capabilities shipped in Go 1.22.

The task

First of all, I'll assume the reader knows what a REST server is. If you need a refresher, this is a good resource, but there are many others. The rest of the series assumes you know what I mean by a "path", "HTTP header", "response code", etc.

In our case, the server is a simple backend for a task management application (think Google Keep, Todoist and the like); it presents the following REST API to clients [1]:

POST   /task/              :  create a task, returns ID
GET    /task/<taskid>      :  returns a single task by ID
GET    /task/              :  returns all tasks
DELETE /task/<taskid>      :  delete a task by ID
GET    /tag/<tagname>      :  returns list of tasks with this tag
GET    /due/<yy>/<mm>/<dd> :  returns list of tasks due by this date

Our server supports GET, POST and DELETE requests, some of them with several potential paths. The parts between angle brackets <...> denote parameters that the client supplies as part of the request; for example, GET /task/42 is a request to fetch the task with ID 42, etc. Tasks are uniquely identified by IDs.

The data encoding is JSON. In POST /task/ the client will send a JSON representation of the task to create. Similarly, everywhere it says the server "returns" something, the returned data is encoded as JSON in the body of the HTTP response.

Code

The rest of this post will present the server's code in Go, in parts. The complete code for the server can be found here; it's a self-contained Go module, with no dependencies. Once you clone or copy the project directory, you can run the server without installing anything:

$ SERVERPORT=4112 go run .

Note that SERVERPORT can be any port; this is the TCP port your local server is listening on. Once the server is running, you can interact with it in a separate terminal by using curl commands, or in any other way that works for you. See this script for an example; the directory containing this script also has an automated test harness for the server.

The model

Let's start by discussing the model (or the "data layer") for our server - the taskstore package (internal/taskstore in the project directory). This is a simple abstraction representing a database of tasks; here is its API:

func New() *TaskStore

// CreateTask creates a new task in the store.
func (ts *TaskStore) CreateTask(text string, tags []string, due time.Time) int

// GetTask retrieves a task from the store, by id. If no such id exists, an
// error is returned.
func (ts *TaskStore) GetTask(id int) (Task, error)

// DeleteTask deletes the task with the given id. If no such id exists, an error
// is returned.
func (ts *TaskStore) DeleteTask(id int) error

// DeleteAllTasks deletes all tasks in the store.
func (ts *TaskStore) DeleteAllTasks() error

// GetAllTasks returns all the tasks in the store, in arbitrary order.
func (ts *TaskStore) GetAllTasks() []Task

// GetTasksByTag returns all the tasks that have the given tag, in arbitrary
// order.
func (ts *TaskStore) GetTasksByTag(tag string) []Task

// GetTasksByDueDate returns all the tasks that have the given due date, in
// arbitrary order.
func (ts *TaskStore) GetTasksByDueDate(year int, month time.Month, day int) []Task

And the Task type is:

type Task struct {
  Id   int       `json:"id"`
  Text string    `json:"text"`
  Tags []string  `json:"tags"`
  Due  time.Time `json:"due"`
}

The taskstore package implements this API using a simple map[int]Task, but you could easily imagine it being implemented using a database. In a realistic application, TaskStore would likely be an interface that several backends can implement, but for our simple example the current API is sufficient. If you'd like an extended exercise, go ahead and implement a TaskStore using something like MongoDB.

Setting up the server

The main function of our server is fairly simple:

func main() {
  mux := http.NewServeMux()
  server := NewTaskServer()
  mux.HandleFunc("POST /task/", server.createTaskHandler)
  mux.HandleFunc("GET /task/", server.getAllTasksHandler)
  mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
  mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
  mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
  mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
  mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)

  log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), mux))
}

Let's spend a moment talking about NewTaskServer, and then we'll come back to discuss the router and path handlers.

NewTaskServer is a constructor for our server type, taskServer. The server wraps a TaskStore, which is safe for concurrent access.

type taskServer struct {
  store *taskstore.TaskStore
}

func NewTaskServer() *taskServer {
  store := taskstore.New()
  return &taskServer{store: store}
}

Routing and handlers

Back to the routing, using the standard HTTP multiplexer included in the net/http package:

mux.HandleFunc("POST /task/", server.createTaskHandler)
mux.HandleFunc("GET /task/", server.getAllTasksHandler)
mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)

The standard multiplexer is simple, yet sufficiently powerful for the majority of use cases.

Let's examine getTaskHandler in detail:

func (ts *taskServer) getTaskHandler(w http.ResponseWriter, req *http.Request) {
  log.Printf("handling get task at %s\n", req.URL.Path)

  id, err := strconv.Atoi(req.PathValue("id"))
  if err != nil {
    http.Error(w, "invalid id", http.StatusBadRequest)
    return
  }

  task, err := ts.store.GetTask(id)
  if err != nil {
    http.Error(w, err.Error(), http.StatusNotFound)
    return
  }

  js, err := json.Marshal(task)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
  w.Header().Set("Content-Type", "application/json")
  w.Write(js)
}

As a reminder, the route defined for the multiplexer is:

mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)

So the multiplexer already did some work for us: it matched the HTTP method (only GET requests will get to getTaskHandler) and it matched the path, including the {id} variable, which can be fetched from the request with PathValue.

The handler itself handler has two main jobs:

  1. Get data from the model (TaskStore)
  2. Fill in an HTTP response for the client

Both are straightforward, but if you examine the other handlers in the server you'll notice that the second is a bit repetitive - marshal the JSON, write the right HTTP response header, etc. We'll get back to this later on.

The rest of the code is more of the same and should be fairly straightforward to understand. The only handler that's a bit special is createTaskHandler, since it has to parse JSON data sent by the client in the request body. There are some nuances to JSON parsing in requests that I didn't cover - check out this post for a more thorough approach.

Making improvements

Now that we have the basic version of the server working, it's time to think about potential issues and improvements.

One obvious place we can improve is the repetitive JSON rendering in our HTTP responses, as mentioned earlier. For this, I created a separate version of the server called stdlib-factorjson. I've kept it separate to help you easily diff it vs. the original server and see what changed. The main novelty it contains is this function:

// renderJSON renders 'v' as JSON and writes it as a response into w.
func renderJSON(w http.ResponseWriter, v interface{}) {
  js, err := json.Marshal(v)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
  w.Header().Set("Content-Type", "application/json")
  w.Write(js)
}

Using it, we can rewrite all our handlers to be a bit more succinct; for example, getTaskHandler now becomes:

func (ts *taskServer) getTaskHandler(w http.ResponseWriter, req *http.Request) {
  log.Printf("handling get task at %s\n", req.URL.Path)

  id, err := strconv.Atoi(req.PathValue("id"))
  if err != nil {
    http.Error(w, "invalid id", http.StatusBadRequest)
    return
  }

  task, err := ts.store.GetTask(id)
  if err != nil {
    http.Error(w, err.Error(), http.StatusNotFound)
    return
  }

  renderJSON(w, task)
}

In part 2 we'll talk about 3rd-party router packages and compare them to the standard library.


[1]Note the ad-hoc nature of specifying the REST API for the server. We'll discuss more structured/standard ways in future parts of this series.