Handling HTTP requests with go-chi

RESTful API principles dictate the way applications send and retrieve data from API services. We will be implementing our RESTful API service using HTTP as the transportation mechanism. We will take a look at how to provide this with the Go standard library, and then review the usage with an update to go-chi/chi.

Setting up a simple web server

Scalability is usually a thing which is achieved by using a specialized load balancer or reverse proxy. Commonly deployed load balancers might be nginx, haproxy or traefik (the latter written in Go). These load balancers are used to distribute incoming traffic between two or mode back-end services, as much for redundancy in case of outages, as well as scaling in the case where a single instance wouldn’t be able to handle the load.

The approach how to handle back-end services varies. PHP implements a FastCGI interface, with which a web serve communicates with PHP. If you would be using Node, most likely you’d start your own HTTP server, and use the excellent express.js to route request endpoints to individual API implementations. Much like with Node, you would also implement a HTTP server in Go to do the same thing. The principles don’t change much.

Let’s use the standard library to create a miniature HTTP server, that will print out the current time on any request issued to it.

package main

import (
	"fmt"
	"net/http"
	"time"
)

func requestHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "The current time is: %s\n", time.Now())
}

func main() {
	fmt.Println("Starting server on port :3000")
	http.HandleFunc("/", requestHandler)
	err := http.ListenAndServe(":3000", nil)
	if err != nil {
		fmt.Println("ListenAndServe:", err)
	}
}

Run the server with go run server1.go to start the server, press CTRL+C to terminate it. When you start the server, open up another terminal and issue a request to see the output:

# curl -sS http://localhost:3000/
The current time is: 2018-03-12 17:31:38.890281205 +0000 UTC m=+149.061759235

In theory, you would write your individual endpoints as such - microservices. But in practice, it’s common to group requests from the same problem domain into a single HTTP service. In order to achieve that, we have to add routing to our application.

Routing logic

When writing an API service, we have to think about routing logic. Routing is basically a way to map the request URL to a handler which provides the response. With this, we will implement different response logic for defined routes. For example:

  1. a /time entrypoint which will return the current time,
  2. a /say entrypoint which will respond with a Hello based on a parameter “name”

While the standard library doesn’t give us a lot of features, it’s fairly easy to set up the HTTP handlers to respond to these individual endpoints.

In the /say endpoint, we want to read the parameter “name”, print a greeting if it’s supplied, or just print Hello ... you. if the parameter is omitted.

package main

import (
	"fmt"
	"net/http"
	"time"
)

func requestTime(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "The current time is: %s\n", time.Now())
}

func requestSay(w http.ResponseWriter, r *http.Request) {
	val := r.FormValue("name")
	if val != "" {
		fmt.Fprintf(w, "Hello %s!", val)
	} else {
		fmt.Fprintf(w, "Hello ... you.")
	}
}

func main() {
	fmt.Println("Starting server on port :3000")

	http.HandleFunc("/time", requestTime)
	http.HandleFunc("/say", requestSay)

	err := http.ListenAndServe(":3000", nil)
	if err != nil {
		fmt.Println("ListenAndServe:", err)
	}
}

In requestSay we retrieve the parameter “name”, using the r.FormValue. If this parameter isn’t present, the returned value will be an empty string.

# curl -sS http://localhost:3000/say
Hello ... you.
# curl -sS http://localhost:3000/say?name=Tit%20Petric
Hello Tit Petric!

Note: The HandleFunc is very explicit about the URL. For example, the server will respond to requests that are issued to /time, but not /time/ (trailing slash added). In the same way, it will respond with a 404 error even to a / (index) request, because there is no handler matching that URL. If we would declare a handler for “/”, it would catch all requests not mached elsewhere.

Routing and middleware with go-chi

There are several frameworks available for HTTP routing in Go, adding various features that you’d have to bolt onto the standard library. The most often used feature, in addition to routing, is providing middleware that will add CORS response headers, or print out logging information for issued requests.

The current favorite go-chi/chi provides both a router, and optional handlers in the form of subpackages. The router is expressive and allows for much more flexibility than the standard library - adding support for URL parameters, and grouping of routes.

Add the following imports:

"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"

And we can modify our example, to add logging middleware and to create a subroute with an URL parameter. We want our microservice to respond to requests matching /say/{name}. URL parameters in chi are defined by encapsulating them in curly braces.

func main() {
	fmt.Println("Starting server on port :3000")

	r := chi.NewRouter()
	r.Use(middleware.Logger)

	r.Get("/time", requestTime)
	r.Route("/say", func(r chi.Router) {
		r.Get("/{name}", requestSay)
		r.Get("/", requestSay)
	})

	err := http.ListenAndServe(":3000", r)
	if err != nil {
		fmt.Println("ListenAndServe:", err)
	}
}

When it comes to URL parameters in chi, they are mandatory. If we would just create a route with r.Get("/say/{name}",..., the route wouldn’t match /say or /say/ requests. We resort to nested routes in this case, where we register handlers for both the parametrized route (/{name}) and the default handler.

# curl -sS http://localhost:3000/say
Hello ... you.
# curl -sS http://localhost:3000/say/Tit%20Petric
Hello Tit Petric!

As chi contains a number of middleware implementations, we can immediately resort to middleware.Logger, which will log the request details to the standard output.

# go run server3.go
Starting server on port :3000
2018/03/12 18:36:03 "GET http://localhost:3000/say HTTP/1.1" from [::1]:33454 - 200 15B in 14.7µs
2018/03/12 18:36:09 "GET http://localhost:3000/say/Tit%20Petric HTTP/1.1" from [::1]:33456 - 200 18B in 13.499µs

In order to make this work, we had to make a small change to our requestSay handler. In order to read the URL parameter value, we have to call chi.URLParam:

func requestSay(w http.ResponseWriter, r *http.Request) {
	val := chi.URLParam(r, "name")
	if val != "" {
		fmt.Fprintf(w, "Hello %s!\n", val)
	} else {
		fmt.Fprintf(w, "Hello ... you.\n")
	}
}

As you see, the rest of the function stays unchanged. This demonstrates a good characteristic of chi itself, the fact that it’s compatible with the Go standard library. This means, if you find yourself in a need to migrate from one to the other, you can do this with only minor refactoring.

Advanced middlewares

A common requirement of API implementations is to provide CORS headers in the HTTP response. We can use special CORS auxiliary middleware from the go-chi project, in order to add them to our microservice.

Add the following import:

"github.com/go-chi/cors"

After creating the router, you can use the CORS middleware like so:

cors := cors.New(cors.Options{
	AllowedOrigins:   []string{"*"},
	AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
	AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
	AllowCredentials: true,
	MaxAge:           300, // Maximum value not ignored by any of major browsers
})
r.Use(cors.Handler)

After restarting the server, we can verify that the response includes the expected headers:

# telnet localhost 3000
Trying ::1...
Connected to localhost.
Escape character is '^]'.
GET /say/Tit HTTP/1.0
Origin: my.dev.hostname.local

HTTP/1.0 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: my.dev.hostname.local
Vary: Origin
Date: Mon, 12 Mar 2018 18:53:20 GMT
Content-Length: 11
Content-Type: text/plain; charset=utf-8

Hello Tit!
Connection closed by foreign host.

CORS headers are added to every response, for requests that include the required Origin header. There are other auxiliary middlewares available, handling JWT authentication for example. You should familiarize yourself with the complete list, as it might save you a lot of development time.

Final words

The above article is the updated chapter #5 of API Foundations in Go. Consider buying it if you like the article. Currently I’m updating the book for Go 1.10 and would love your support. The book samples for my published works are available on GitHub. I also have a patron page set up so you can buy me coffee for when I write long into the night. Programmers work at night, right?

While I have you here...

It would be great if you buy one of my books:

I promise you'll learn a lot more if you buy one. Buying a copy supports me writing more about similar topics. Say thank you and buy my books.

Feel free to send me an email if you want to book my time for consultancy/freelance services. I'm great at APIs, Go, Docker, VueJS and scaling services, among many other things.