Featured image of post Building a web service with go-tool-base, part 3.5: the same server, now a website

Building a web service with go-tool-base, part 3.5: the same server, now a website

The HTTP server from part 3 serves JSON. But net/http doesn’t care what you hand it: HTML, an image, a stylesheet, a whole little site, it’s all just bytes with a content type. So before we get back to the API in part 4, a short detour to prove the point and pick up a couple of genuinely useful tools: we’ll turn the macguffin service into a tiny website.

This is a bonus, off to the side of the API arc, but it earns its place. Real services nearly always grow a bit of HTML eventually: a status page, a landing page, a small admin view, an embedded docs site (we’ll do exactly that in part 5). The mechanics are the same every time, and worth having in hand.

A page from html/template

Go’s html/template renders HTML from a template and your data, and it escapes that data on the way out, so a macguffin called <script> becomes text rather than a problem. Here’s a page that lists the catalogue:

<!-- internal/site/templates/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Macguffins</title>
    <link rel="stylesheet" href="/static/style.css" />
  </head>
  <body>
    <h1>The macguffin catalogue</h1>
    <ul>
      {{range .}}
      <li>{{.Name}} <span class="qty">&times;{{.Quantity}}</span></li>
      {{end}}
    </ul>
  </body>
</html>

{{range .}} walks the slice we pass in, and {{.Name}} / {{.Quantity}} read each macguffin’s fields. The data is the same Store from part 2, so the page is a view onto the very same domain the gRPC and JSON APIs serve.

Shipping the files inside the binary

A template and a stylesheet are files, and you do not want to deploy a folder of loose assets next to your binary and hope they line up. Go’s embed package bakes them into the binary at build time, so the whole thing ships as one file.

// internal/site/site.go

package site

import (
	"embed"
	"html/template"
	"io/fs"
	"net/http"

	"gitlab.com/myorg/macguffinsvc/internal/macguffin"
)

//go:embed templates static
var content embed.FS

var tmpl = template.Must(template.ParseFS(content, "templates/index.html"))

type Site struct {
	store *macguffin.Store
}

func New(store *macguffin.Store) *Site {
	return &Site{store: store}
}

func (s *Site) Routes() *http.ServeMux {
	static, err := fs.Sub(content, "static")
	if err != nil {
		panic(err) // the embedded path is a compile-time constant
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", s.index)
	mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))

	return mux
}

func (s *Site) index(w http.ResponseWriter, _ *http.Request) {
	if err := tmpl.Execute(w, s.store.List()); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

Three things are doing the work. //go:embed templates static pulls both folders into the content filesystem. template.ParseFS parses the page from it once at startup. And http.FileServer(http.FS(static)) serves the stylesheet (and anything else under static/) straight from the embedded files, with content types set for you, so GET /static/style.css comes back as text/css.

The GET /{$} pattern is worth a note: the {$} anchors it to the exact root path, so / renders the page but /anything-else doesn’t accidentally match it.

If you’d rather edit templates without rebuilding during development, swap the embedded filesystem for the real one: http.FileServer(http.Dir("static")), and template.ParseGlob instead of ParseFS. Embed for release, disk for the edit-refresh loop; the handlers don’t change.

On the same server

Routes() hands back a *http.ServeMux, which is an http.Handler, so it registers exactly like the JSON API did, on the same controller, with the same TLS:

// pkg/cmd/serve/main.go (or a dedicated command)

if _, err := gtbhttp.Register(ctx, "site", controller, p.Config, p.Logger,
	site.New(store).Routes()); err != nil {
	return err
}

Because the certificate is the mkcert one from part 2, opening https://localhost:8443/ renders the page, stylesheet and all, with a clean padlock on any machine that trusts your local CA (where you ran mkcert -install); anywhere else, the browser flags the cert, exactly as it should.

The macguffin catalogue rendered in a browser, served over HTTPS by the same server as the API

The same hardened server, the same graceful shutdown, the same /healthz, now serving a website instead of (or alongside) JSON.

Back to the API

That’s the whole trick: the HTTP server is just net/http, and it will serve whatever you point it at, escaped and content-typed properly, shipped inside the binary. We’ll use exactly this in part 5 to serve interactive API docs.

Detour over. In part 4 we get back to the API and finally deal with that duplicated REST layer, the one we wrote twice and promised to delete.

Built with Hugo
Theme Stack designed by Jimmy