Serve a web page¶
By the end of this, https://localhost:8443/ shows a page listing the
macguffins, with a stylesheet, served by the same HTTP server as the REST API.
The template and the stylesheet are baked into the binary, so mytool still
ships as one file.
This is a short side step in the web-service series, after Add a REST API. A later part uses the same technique to serve API docs.
Allow about fifteen minutes.
Before you start¶
- The REST API from Add a REST API, registered on the
controller as
http. Either version of the handler works. - The same gtb release. Everything below is verified against v0.50.0.
Write the template¶
Go's html/template renders HTML from your data, and escapes that data as it
goes. Create 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">×{{.Quantity}}</span></li>
{{end}}
</ul>
</body>
</html>
{{range .}} walks the list of macguffins the page is given, and {{.Name}}
and {{.Quantity}} read each one's fields.
And a stylesheet, internal/site/static/style.css:
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; }
.qty { color: #666; }
Embed the files and serve them¶
Create internal/site/site.go:
package site
import (
"embed"
"html/template"
"io/fs"
"net/http"
"github.com/myorg/mytool/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)
}
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 lines do the work:
//go:embed templates staticcopies both folders into the binary at build time, as thecontentfilesystem.template.ParseFSparses the page from that filesystem once, at startup.http.FileServer(http.FS(static))serves everything understatic/from the embedded files, with the right content type.
fs.Sub can only fail if the static folder isn't embedded, which the
//go:embed line rules out, so the panic there never runs.
GET /{$} matches the root path and nothing else, so / renders the page and
/anything-else doesn't.
Put it on the same server¶
The site and the REST API both want the HTTP server, but there's only one.
Registering the site as a second server would make it read the same
server.http block and try to bind the same port. One of the two then fails to
start, gives up, and takes the tool down with exit 1.
So mount both on one handler, and register that once. In
pkg/cmd/serve/main.go, import the site package:
Then replace the HTTP registration with a mux that sends the API's paths to the API and everything else to the site. With the spec-generated handler from the last part, that's:
api := restapi.HandlerFromMux(restapi.NewAPI(store), http.NewServeMux())
mux := http.NewServeMux()
mux.Handle("/macguffins", api)
mux.Handle("/macguffins/", api)
mux.Handle("/", site.New(store).Routes())
if _, err := gtbhttp.RegisterFromReader(ctx, "http", controller, props.Config.View(), props.Logger, mux); err != nil {
return err
}
If you kept the hand-written handler, set api to resthand.New(store).Routes()
instead. /macguffins is the list and /macguffins/ covers each item. / is the
catch-all, so the site gets the root page and the stylesheet, and its own mux
answers 404 for anything else. The health endpoints stay where they were,
because RegisterFromReader mounts them outside your handler.
Build and run:
Look at it¶
Open https://localhost:8443/ in a browser. If you ran mkcert -install, the
page loads with no certificate warning. Or fetch it with curl:
<!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>
<li>maltese-falcon <span class="qty">×1</span></li>
</ul>
</body>
</html>
The stylesheet comes back with its content type set:
curl --cacert "$(mkcert -CAROOT)/rootCA.pem" -o /dev/null -w '%{http_code} %{content_type}\n' https://localhost:8443/static/style.css
/macguffins still answers JSON, and /healthz still reports both grpc and
http.
Check the escaping¶
Create a macguffin whose name is a script tag:
curl --cacert "$(mkcert -CAROOT)/rootCA.pem" -d '{"name":"<script>alert(1)</script>","quantity":1}' https://localhost:8443/macguffins
Reload the page. The name shows up as text, because the template escaped it:
That's html/template doing its job. text/template would have written the
tag out as it is, and the browser would have run it.
Edit without rebuilding¶
The embedded files only change when you rebuild. While you're working on the
page, you can read from disk instead: template.ParseGlob in place of
ParseFS, and http.FileServer(http.Dir("internal/site/static")) in place of
the embedded filesystem. The handlers don't change. Switch back to embed
before you release, or the binary will look for files that aren't there.
Where this leaves you¶
The HTTP server serves whatever handler you give it: JSON, HTML or files, over the same TLS, with the same health endpoints and shutdown. The page ships inside the binary. Next, the series goes back to the API and stops writing the REST layer by hand.
What to read next¶
- HTTP component: middleware, timeouts and
what
RegisterFromReadermounts. html/templateandembed: the standard-library packages this page uses.