Skip to content

Instantly share code, notes, and snippets.

@reagent
Last active January 29, 2024 13:31
Show Gist options
  • Save reagent/043da4661d2984e9ecb1ccb5343bf438 to your computer and use it in GitHub Desktop.
Save reagent/043da4661d2984e9ecb1ccb5343bf438 to your computer and use it in GitHub Desktop.
Custom HTTP Routing in Go

Custom HTTP Routing in Go

Basic Routing

Responding to requests via simple route matching is built in to Go's net/http standard library package. Just register the path prefixes and callbacks you want invoked and then call the ListenAndServe to have the default request handler invoked on each request. For example:

package main

import (
	"io"
	"log"
	"net/http"
)

func main() {

	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain")
		w.WriteHeader(http.StatusOK)

		io.WriteString(w, "Hello world\n")
	})

	err := http.ListenAndServe(":9000", nil)

	if err != nil {
		log.Fatalf("Could not start server: %s\n", err.Error())
	}
}

While it may look strange to pass nil as the second parameter to ListenAndServe, this causes Go to use the DefaultServeMux request multiplexer. Requests to the configured endpoint look like this:

$ curl -i http://localhost:9000/hello
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Mon, 26 Jun 2017 23:58:40 GMT
Content-Length: 12

Hello world

By default, this handler will respond with a 404 when it can't find a match:

$ curl -i http://localhost:9000/
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Mon, 26 Jun 2017 23:59:11 GMT
Content-Length: 19

404 page not found

As you can see above, we're not controlling the content of the response, so the Content-Type header reverts to Go's default. If you would prefer not to rely on the "magic" of the default multiplexer, you can configure your own by creating a ServeMux instance.

Routing with ServeMux

The process of registering callbacks with this method is similar to the previous example, but in this case we call HandleFunc on the multiplexer that we create:

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
)

func main() {
	handler := http.NewServeMux()

	handler.HandleFunc("/hello/", func(w http.ResponseWriter, r *http.Request) {
		name := strings.Replace(r.URL.Path, "/hello/", "", 1)

		w.Header().Set("Content-Type", "text/plain")
		w.WriteHeader(http.StatusOK)

		io.WriteString(w, fmt.Sprintf("Hello %s\n", name))
	})

	handler.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain")
		w.WriteHeader(http.StatusOK)

		io.WriteString(w, "Hello world\n")
	})

	handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain")
		w.WriteHeader(http.StatusNotFound)

		io.WriteString(w, "Not found\n")
	})

	err := http.ListenAndServe(":9000", handler)

	if err != nil {
		log.Fatalf("Could not start server: %s\n", err.Error())
	}
}

You'll notice two changes here:

  1. The route prefix for /hello/ that will match against any subtree that matches this prefix
  2. The custom handler for / that responds with a 404 status when the request was not matched by any previous pattern

Here are the responses for each:

$ curl -i http://localhost:9000/hello/Patrick
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Tue, 27 Jun 2017 04:44:30 GMT
Content-Length: 14

Hello Patrick
$ curl -i http://localhost:9000/asdf
HTTP/1.1 404 Not Found
Content-Type: text/plain
Date: Tue, 27 Jun 2017 04:44:36 GMT
Content-Length: 10

Not found

The duplication present in each handler is something that can be easily refactored by moving away from interacting with http.ResponseWriter directly, so we'll do that next.

Customizing the HTTP Response

The common tasks of writing the Content-Type header, setting the HTTP status, and returning the body can be moved to a single function by embedding http.ResponseWriter in a new struct and moving the duplicate code to our new struct. For example:

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
)

type Response struct {
	http.ResponseWriter
}

func (r *Response) Text(code int, body string) {
	r.Header().Set("Content-Type", "text/plain")
	r.WriteHeader(code)

	io.WriteString(r, fmt.Sprintf("%s\n", body))
}

func main() {
	handler := http.NewServeMux()

	handler.HandleFunc("/hello/", func(w http.ResponseWriter, r *http.Request) {
		name := strings.Replace(r.URL.Path, "/hello/", "", 1)

		resp := Response{w}
		resp.Text(http.StatusOK, fmt.Sprintf("Hello %s", name))
	})

	handler.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		resp := Response{w}
		resp.Text(http.StatusOK, "Hello world")
	})

	handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		resp := Response{w}
		resp.Text(http.StatusNotFound, "Not found")
	})

	err := http.ListenAndServe(":9000", handler)

	if err != nil {
		log.Fatalf("Could not start server: %s\n", err.Error())
	}

}

The only difference between this and the previous example is that we've condensed the 3 lines needed to write a response down to a single call to Response.Text() to set the status and send the body to the client. While assigning a local Response instance is tedious, it's necessary in this case due to the specific function signature required by HandleFunc. To see how we might simplify this, we'll have to write our own HTTP handler.

Writing a Custom Handler

Per the documentation, a struct can be treated as a handler if it implements the ServeHTTP() method. So, if we wanted to ditch the request multiplexer altogether, we could respond to requests directly from a custom handler defined in a new struct:

package main

import (
	"io"
	"log"
	"net/http"
)

type App struct{}

func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	w.WriteHeader(http.StatusOK)

	io.WriteString(w, "Hello world\n")
}

func main() {
	err := http.ListenAndServe(":9000", &App{})

	if err != nil {
		log.Fatalf("Could not start server: %s\n", err.Error())
	}
}

Since our App struct responds to ServeHTTP, we can pass it directly to ListenAndServe and it will receive all HTTP requests. In this simple example, all requests receive the same response regardless of the request path:

$ curl -i http://localhost:9000/ok/pal
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Tue, 27 Jun 2017 04:58:39 GMT
Content-Length: 12

Hello world

This isn't very interesting in itself, but it does now give us the ability to wrap each incoming request before passing it off to a custom handler function.

Custom Regular Expression-Based Router

By using a custom handler, we now have control over when the requests are intercepted, we can pass our own request and response structs to the matched route. Since we are no longer using a http.ServeMux instance, I've introduced a custom regular expression-based router:

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"regexp"
)

type Handler func(*Response, *Request)

type Route struct {
	Pattern *regexp.Regexp
	Handler Handler
}

type App struct {
	Routes       []Route
	DefaultRoute Handler
}

func NewApp() *App {
	app := &App{
		DefaultRoute: func(resp *Response, req *Request) {
			resp.Text(http.StatusNotFound, "Not found")
		},
	}

	return app
}

func (a *App) Handle(pattern string, handler Handler) {
	re := regexp.MustCompile(pattern)
	route := Route{Pattern: re, Handler: handler}

	a.Routes = append(a.Routes, route)
}

func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	req := &Request{Request: r}
	resp := &Response{w}

	for _, rt := range a.Routes {
		if matches := rt.Pattern.FindStringSubmatch(r.URL.Path); len(matches) > 0 {
			if len(matches) > 1 {
				req.Params = matches[1:]
			}

			rt.Handler(resp, req)
			return
		}
	}

	a.DefaultRoute(resp, req)
}

type Request struct {
	*http.Request
	Params []string
}

type Response struct {
	http.ResponseWriter
}

func (r *Response) Text(code int, body string) {
	r.Header().Set("Content-Type", "text/plain")
	r.WriteHeader(code)

	io.WriteString(r, fmt.Sprintf("%s\n", body))
}

func main() {
	app := NewApp()

	app.Handle(`^/hello$`, func(resp *Response, req *Request) {
		resp.Text(http.StatusOK, "Hello world")
	})

	app.Handle(`/hello/([\w\._-]+)$`, func(resp *Response, req *Request) {
		resp.Text(http.StatusOK, fmt.Sprintf("Hello %s", req.Params[0]))
	})

	err := http.ListenAndServe(":9000", app)

	if err != nil {
		log.Fatalf("Could not start server: %s\n", err.Error())
	}

}

The App struct now has a collection of routes, each with a corresponding callback. If the request path matches the configured pattern, that callback will be triggered. Otherwise, the default route will be invoked and the server will respond with a 404 status:

$ curl -i http://localhost:9000/hello
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Tue, 27 Jun 2017 14:39:24 GMT
Content-Length: 12

Hello world
$ curl -i http://localhost:9000/hello/Patrick
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Tue, 27 Jun 2017 14:39:28 GMT
Content-Length: 14

Hello Patrick
$ curl -i http://localhost:9000/missing
HTTP/1.1 404 Not Found
Content-Type: text/plain
Date: Tue, 27 Jun 2017 14:39:32 GMT
Content-Length: 10

Not found

The custom Request struct is worth examining -- rather than performing substring replacement to determine the dynamic message, it keeps track of any capture groups in the route pattern and exposes them through the Params field. While this gets the job done, it's not ideal from a design perspective.

Wrapping it All in a Context

Rather than storing Params on the Request struct, we can instead introduce another struct to capture these values and use type embedding to have http.Request andhttp.ResponseWriter handle the traditional HTTP interactions. This will also simplify the Handler signature as it now only needs to take a single Context struct and can perform all the response handling needed:

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"regexp"
)

type Handler func(*Context)

type Route struct {
	Pattern *regexp.Regexp
	Handler Handler
}

type App struct {
	Routes       []Route
	DefaultRoute Handler
}

func NewApp() *App {
	app := &App{
		DefaultRoute: func(ctx *Context) {
			ctx.Text(http.StatusNotFound, "Not found")
		},
	}

	return app
}

func (a *App) Handle(pattern string, handler Handler) {
	re := regexp.MustCompile(pattern)
	route := Route{Pattern: re, Handler: handler}

	a.Routes = append(a.Routes, route)
}

func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	ctx := &Context{Request: r, ResponseWriter: w}

	for _, rt := range a.Routes {
		if matches := rt.Pattern.FindStringSubmatch(ctx.URL.Path); len(matches) > 0 {
			if len(matches) > 1 {
				ctx.Params = matches[1:]
			}

			rt.Handler(ctx)
			return
		}
	}

	a.DefaultRoute(ctx)
}

type Context struct {
	http.ResponseWriter
	*http.Request
	Params []string
}

func (c *Context) Text(code int, body string) {
	c.ResponseWriter.Header().Set("Content-Type", "text/plain")
	c.WriteHeader(code)

	io.WriteString(c.ResponseWriter, fmt.Sprintf("%s\n", body))
}

func main() {
	app := NewApp()

	app.Handle(`^/hello$`, func(ctx *Context) {
		ctx.Text(http.StatusOK, "Hello world")
	})

	app.Handle(`/hello/([\w\._-]+)$`, func(ctx *Context) {
		ctx.Text(http.StatusOK, fmt.Sprintf("Hello %s", ctx.Params[0]))
	})

	err := http.ListenAndServe(":9000", app)

	if err != nil {
		log.Fatalf("Could not start server: %s\n", err.Error())
	}

}

This is just the start of what's possible when customizing an HTTP response handler. You can take this further by:

  • Matching requests against a specific HTTP method (e.g. GET / POST) and having different handler for the different request types
  • Matching against Content-Type to invoke a separate handler for different content requests (e.g. text/html, application/json, etc...)
  • Adding additional response methods (e.g. ctx.JSON) to send a more appropriate response for the requested Content-Type
package main
import (
"io"
"log"
"net/http"
)
func main() {
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
io.WriteString(w, "Hello world\n")
})
err := http.ListenAndServe(":9000", nil)
if err != nil {
log.Fatalf("Could not start server: %s\n", err.Error())
}
}
package main
import (
"fmt"
"io"
"log"
"net/http"
"strings"
)
func main() {
handler := http.NewServeMux()
handler.HandleFunc("/hello/", func(w http.ResponseWriter, r *http.Request) {
name := strings.Replace(r.URL.Path, "/hello/", "", 1)
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
io.WriteString(w, fmt.Sprintf("Hello %s\n", name))
})
handler.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
io.WriteString(w, "Hello world\n")
})
handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
io.WriteString(w, "Not found\n")
})
err := http.ListenAndServe(":9000", handler)
if err != nil {
log.Fatalf("Could not start server: %s\n", err.Error())
}
}
package main
import (
"fmt"
"io"
"log"
"net/http"
"strings"
)
type Response struct {
http.ResponseWriter
}
func (r *Response) Text(code int, body string) {
r.Header().Set("Content-Type", "text/plain")
r.WriteHeader(code)
io.WriteString(r, fmt.Sprintf("%s\n", body))
}
func main() {
handler := http.NewServeMux()
handler.HandleFunc("/hello/", func(w http.ResponseWriter, r *http.Request) {
name := strings.Replace(r.URL.Path, "/hello/", "", 1)
resp := Response{w}
resp.Text(http.StatusOK, fmt.Sprintf("Hello %s", name))
})
handler.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
resp := Response{w}
resp.Text(http.StatusOK, "Hello world")
})
handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
resp := Response{w}
resp.Text(http.StatusNotFound, "Not found")
})
err := http.ListenAndServe(":9000", handler)
if err != nil {
log.Fatalf("Could not start server: %s\n", err.Error())
}
}
package main
import (
"io"
"log"
"net/http"
)
type App struct{}
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
io.WriteString(w, "Hello world\n")
}
func main() {
err := http.ListenAndServe(":9000", &App{})
if err != nil {
log.Fatalf("Could not start server: %s\n", err.Error())
}
}
package main
import (
"fmt"
"io"
"log"
"net/http"
"regexp"
)
type Handler func(*Response, *Request)
type Route struct {
Pattern *regexp.Regexp
Handler Handler
}
type App struct {
Routes []Route
DefaultRoute Handler
}
func NewApp() *App {
app := &App{
DefaultRoute: func(resp *Response, req *Request) {
resp.Text(http.StatusNotFound, "Not found")
},
}
return app
}
func (a *App) Handle(pattern string, handler Handler) {
re := regexp.MustCompile(pattern)
route := Route{Pattern: re, Handler: handler}
a.Routes = append(a.Routes, route)
}
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
req := &Request{Request: r}
resp := &Response{w}
for _, rt := range a.Routes {
if matches := rt.Pattern.FindStringSubmatch(r.URL.Path); len(matches) > 0 {
if len(matches) > 1 {
req.Params = matches[1:]
}
rt.Handler(resp, req)
return
}
}
a.DefaultRoute(resp, req)
}
type Request struct {
*http.Request
Params []string
}
type Response struct {
http.ResponseWriter
}
func (r *Response) Text(code int, body string) {
r.Header().Set("Content-Type", "text/plain")
r.WriteHeader(code)
io.WriteString(r, fmt.Sprintf("%s\n", body))
}
func main() {
app := NewApp()
app.Handle(`^/hello$`, func(resp *Response, req *Request) {
resp.Text(http.StatusOK, "Hello world")
})
app.Handle(`/hello/([\w\._-]+)$`, func(resp *Response, req *Request) {
resp.Text(http.StatusOK, fmt.Sprintf("Hello %s", req.Params[0]))
})
err := http.ListenAndServe(":9000", app)
if err != nil {
log.Fatalf("Could not start server: %s\n", err.Error())
}
}
package main
import (
"fmt"
"io"
"log"
"net/http"
"regexp"
)
type Handler func(*Context)
type Route struct {
Pattern *regexp.Regexp
Handler Handler
}
type App struct {
Routes []Route
DefaultRoute Handler
}
func NewApp() *App {
app := &App{
DefaultRoute: func(ctx *Context) {
ctx.Text(http.StatusNotFound, "Not found")
},
}
return app
}
func (a *App) Handle(pattern string, handler Handler) {
re := regexp.MustCompile(pattern)
route := Route{Pattern: re, Handler: handler}
a.Routes = append(a.Routes, route)
}
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := &Context{Request: r, ResponseWriter: w}
for _, rt := range a.Routes {
if matches := rt.Pattern.FindStringSubmatch(ctx.URL.Path); len(matches) > 0 {
if len(matches) > 1 {
ctx.Params = matches[1:]
}
rt.Handler(ctx)
return
}
}
a.DefaultRoute(ctx)
}
type Context struct {
http.ResponseWriter
*http.Request
Params []string
}
func (c *Context) Text(code int, body string) {
c.ResponseWriter.Header().Set("Content-Type", "text/plain")
c.WriteHeader(code)
io.WriteString(c.ResponseWriter, fmt.Sprintf("%s\n", body))
}
func main() {
app := NewApp()
app.Handle(`^/hello$`, func(ctx *Context) {
ctx.Text(http.StatusOK, "Hello world")
})
app.Handle(`/hello/([\w\._-]+)$`, func(ctx *Context) {
ctx.Text(http.StatusOK, fmt.Sprintf("Hello %s", ctx.Params[0]))
})
err := http.ListenAndServe(":9000", app)
if err != nil {
log.Fatalf("Could not start server: %s\n", err.Error())
}
}
@Sirex2003
Copy link

Thanks for this post. It would be great to add here info about making subroutes.

@cbluth
Copy link

cbluth commented Nov 12, 2019

thanks!

@gauravds
Copy link

thanks

@CheerfulSatchel
Copy link

These are great, thank you 😸

@fzaffa
Copy link

fzaffa commented May 22, 2020

Awesome router, thanks!

How would you handle serving static files? I tried this to no avail.

@hrrmsn
Copy link

hrrmsn commented Jul 17, 2020

Awesome examples. Thanks!
(=

@abdulmlik
Copy link

thanks, this solved my problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment