Building a Basic HTTP Server in Go: A Step-by-Step Tutorial
Tags: go, webdev, beginners, tutorial
Published: 2024-02-25 12:46:52

## Introduction

Go (Golang) is a compiled language with a rich standard library, and is popular for web servers due to its readability and concurrency model. It's statically-typed and garbage-collected, which only adds to its desirability for web servers and microservices.

I've been reading a lot about Go recently, and decided it was time to dive in. This tutorial is part learning, part teaching, so join me in exploring this wonderful language with an application that is relevant and hopefully useful!

You can find [the full code on GitHub](https://github.com/andyjessop/simple-go-server), so if you get lost along the way, you will have a full working reference available.

Together, we'll build a web server using only the standard library of Go - no frameworks - so we can really understand what's going on under the hood. It will be a JSON server that manages posts, and we can create, delete, and fetch our posts. I've omitted updating posts for brevity.

It's not going to be production ready, but will introduce both you and I to some of the core concepts in Go.

I can see that you're on the edge of your seat, so without further ado, let's dive in!

## Setting Up Your Environment

If you haven't already installed Go, please follow the instructions [here](https://go.dev/learn/) and get it downloaded onto your machine.

Make sure to add go to your path:

```bash
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin
```

And either restart your terminal, or source your `.bashrc`.

```bash
source ~/.bashrc  # Or ~/.zshrc or equivalent, depending on your shell
```

Let's get the workspace setup. We'll create the folder that will contain all our files, and ask `go` to initialise a module for us.

```bash
mkdir go-server
cd go-server
go mod init go-server
```

This will create a `go.mod` file in our folder with the following contents.

```go
module go-server

go 1.22.0
```

I think we're ready to go; time to code...

## Writing the HTTP Server Code (`main.go`)

### Initial Setup

Now create a file `main.go` at the root of the folder - this is going to contain all the code for our server. Let's add some boilerplate and the imports we'll need:

```go
package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"strconv"
	"sync"
)

type Post struct {
	ID   int    `json:"id"`
	Body string `json:"body"`
}

var (
	posts   = make(map[int]Post)
	nextID  = 1
	postsMu sync.Mutex
)
```

After the imports, we've added a `Post` struct to define our post data, and defined some global variables:

- `posts` - a `map` that will hold our posts in memory (no DB in this tutorial)
- `nextID` - a variable that will help us create unique post ids when creating a new post
- `postsMu` - a mutex that allows us to lock the program while we make modifications to the `posts` map. If we didn't have this, then concurrent requests could in theory cause a race condition.

### Implementing the Server

Below the declaration of the those variables, add the `main` function, which is the entry point for our module.

```go
func main() {
	http.HandleFunc("/posts", postsHandler)
	http.HandleFunc("/posts/", postHandler)

	fmt.Println("Server is running at http://localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

Let's have a look at what's going on here:

```go
http.HandleFunc("/posts", postsHandler)
http.HandleFunc("/posts/", postHandler)
```

Here we setup handlers for the `/posts` and `/posts/` routes, handled by some as yet non-existent functions.

```go
log.Fatal(http.ListenAndServe(":8080", nil))
```

Fairly straightforward, this is starting the server and listening on port `:8080`, so when we hit `:8080/posts` we will receive an array of posts back.

Now we'll add those handlers.

### Handling Requests

So the way we've set this up is that each route has a handler, whether it's `GET`/`POST`/`DELETE`/`WHATEVER`, we will handle everything in these functions. The handler functions will therefore check the method and decide what to do with the request.

In order to do that, add the following code below the `main` function.

```go
func postsHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case "GET":
		handleGetPosts(w, r)
	case "POST":
		handlePostPosts(w, r)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

func postHandler(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.URL.Path[len("/posts/"):])
	if err != nil {
		http.Error(w, "Invalid post ID", http.StatusBadRequest)
		return
	}

	switch r.Method {
	case "GET":
		handleGetPost(w, r, id)
	case "DELETE":
		handleDeletePost(w, r, id)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}
```

The first thing to notice here is that the handler functions both receive `w http.ResponseWriter, r *http.Request` as their arguments. These two arguments enable us to read the request, and to respond with our JSON.

NB: I've used single-character variables because that seems to be the convention in Go. Coming from a JavaScript background, I would have preferred to defined these as full words, but I'm trying to fit in, so it is what it is.

The rest is all fairly obvious; we're delegating the task of handling the requests to more specific functions targeted to the respective methods. Probably the only interesting thing here is in the `postHandler`, where we're extracting the `id` of the post to handle.

```go
id, err := strconv.Atoi(r.URL.Path[len("/posts/"):])
if err != nil {
    http.Error(w, "Invalid post ID", http.StatusBadRequest)
    return
}
```

This is taking the path as a string - `r.URL.Path` - and creating a substring using the `str[x:y]` syntax where `str` is the original string `x` is the starting index of the substring, and `y` is the end of the substring.

In our case, `y` is omitted, so it's grabbing a substring of the path from the end of `/posts/` to the end of the whole path. Therefore if we have `/posts/123`, this will return `123` as a string.

NB: Note that we could also do `[7:]`, which would yield the same result, but the 7 is then a magic number that doesn't have an obvious reason to be so.

We're then converting it to an integer with `strconv.Atoi`, and handling our errors properly too.

### CRUD Operations

We've come a long way! We created a server that listens on port `8080`, and handles the routes we're interested in. We then implemented those handlers, routing each separate method to its own handler. Let's implement those handlers now.

To make understanding this simpler, I'll post the whole code and then comment all the bits that are interesting. So make sure to give them a good read so that you understand what's going on here.

```go
func handleGetPosts(w http.ResponseWriter, r *http.Request) {
    // This is the first time we're using the mutex.
    // It essentially locks the server so that we can
    // manipulate the posts map without worrying about
    // another request trying to do the same thing at
    // the same time.
	postsMu.Lock()

    // I love this feature of go - we can defer the
    // unlocking until the function has finished executing,
    // but define it up the top with our lock. Nice and neat.
    // Caution: deferred statements are first-in-last-out,
    // which is not all that intuitive to begin with.
	defer postsMu.Unlock()

    // Copying the posts to a new slice of type []Post
	ps := make([]Post, 0, len(posts))
	for _, p := range posts {
		ps = append(ps, p)
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(ps)
}

func handlePostPosts(w http.ResponseWriter, r *http.Request) {
	var p Post

    // This will read the entire body into a byte slice 
    // i.e. ([]byte)
	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Error reading request body", http.StatusInternalServerError)
		return
	}

    // Now we'll try to parse the body. This is similar
    // to JSON.parse in JavaScript.
	if err := json.Unmarshal(body, &p); err != nil {
		http.Error(w, "Error parsing request body", http.StatusBadRequest)
		return
	}

    // As we're going to mutate the posts map, we need to
    // lock the server again
	postsMu.Lock()
	defer postsMu.Unlock()

	p.ID = nextID
	nextID++
	posts[p.ID] = p

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(p)
}

func handleGetPost(w http.ResponseWriter, r *http.Request, id int) {
	postsMu.Lock()
	defer postsMu.Unlock()

	p, ok := posts[id]
	if !ok {
		http.Error(w, "Post not found", http.StatusNotFound)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(p)
}

func handleDeletePost(w http.ResponseWriter, r *http.Request, id int) {
	postsMu.Lock()
	defer postsMu.Unlock()

    // If you use a two-value assignment for accessing a
    // value on a map, you get the value first then an
    // "exists" variable.
	_, ok := posts[id]
	if !ok {
		http.Error(w, "Post not found", http.StatusNotFound)
		return
	}

	delete(posts, id)
	w.WriteHeader(http.StatusOK)
}
```

## Running the Server

Right - we're ready to test our server and see if it works! Drumroll?

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

This command compiles and runs the server. You should see a message indicating that your server is running and listening on port 8080. You can then test your server by navigating to http://localhost:8080/posts in your browser or using a tool like `curl` or Postman to make requests.

## Conclusion

Congratulations! You and I have successfully built a basic HTTP server in Go. This server handles creating, fetching, and deleting posts without relying on external frameworks, and I think we've demonstrated how intuitive and powerful Go is as a fairly low-level language but one that takes the burden of memory management away with its garbage collector.

While our server is functional, there are many ways it could be expanded or improved. If you're the type who likes to do further learning, consider the following:

- Add update functionality to allow editing existing posts.
- Implement data persistence by integrating a database instead of storing posts in memory.
- Add user authentication to control access to certain endpoints.
- Implement input validation to improve security.
- Enhance error handling for more robust and informative responses.

This tutorial only scratches the surface of what's possible with Go. I'm having so much fun learning it, and I don't think this will be the end for my journey with the language.