Part 7 mini-project: Guestbook
Chapter 01 promised three programs: a number guessing game, a text adventure, and a small guestbook web page. You have built the first two. This is the third: visitors leave a message, your program saves it, and it shows it to the next visitor. It is the first project that you open in a browser.
What to build
A server at http://localhost:8080 with two routes:
| Route | What it does |
|---|---|
GET / |
Shows all the messages, oldest first, and a form. |
POST /messages |
Checks the form, saves the message, and redirects to
/. |
The rules:
- A message has a name and a text. Spaces around them are trimmed.
- The name can be at most 40 characters, and the text at most 500. Neither can be empty.
- A bad form gets status 400. The page shows the form again, with a message saying what is wrong, and with what the visitor typed still in the boxes.
- A good form gets status 303, and the browser then asks for
/. - Messages are saved to
messages.json, and are still there after a restart. <script>in a message shows up as text.
The finished server, from real runs. These lines are from the page after three messages, with the last one an attempt to attack it:
<p><strong>Keiko</strong> wrote:</p>
<p class="text">Hello there</p>
<p><strong>Arif</strong> wrote:</p>
<p class="text">Nice page</p>
<p><strong><script>alert("hello")</script></strong> wrote:</p>
<p class="text"><script>alert("hello")</script></p>
The responses to a good form and some bad ones:
curl.exe -i -d "name=Keiko&text=Hello there" http://localhost:8080/messages
HTTP/1.1 303 See Other
Location: /
curl.exe -i -d "name=&text=x" http://localhost:8080/messages
HTTP/1.1 400 Bad Request
<p class="error">Please enter your name.</p>
curl.exe -i -d "name=Keiko&text=" http://localhost:8080/messages
HTTP/1.1 400 Bad Request
<p class="error">Please enter a message.</p>
curl.exe -i http://localhost:8080/messages
HTTP/1.1 405 Method Not Allowed
Allow: POST
Method Not Allowed
A text of 501 characters and a name of 41 characters give these lines:
<p class="error">The message can have at most 500 characters.</p>
<p class="error">The name can have at most 40 characters.</p>
A broken messages.json stops the server at the
start:
Could not load the messages: messages.json is not valid: invalid character 'o' looking for beginning of object key string
Fix or remove the file, then start the server again.
File layout
The project is a module with one package of your own, as in Part 6.
Both versions are under projects/06-guestbook/:
starter/
go.mod
main.go
guestbook/
guestbook.go
templates/
home.html
finished/
go.mod
main.go
guestbook/
guestbook.go
templates/
home.html
To work on the starter, go into its folder and use
go run .:
cd projects/06-guestbook/starter
go run .
To run the finished version:
cd projects/06-guestbook/finished
go run .
To go back up to go-work afterwards:
cd ../../..
Both paths in the program are relative to the folder it runs from: it
reads templates/home.html, and it reads and writes
messages.json. The data file appears next to
main.go the first time a message is saved. Delete it to
start with an empty book. Running from any other folder gives this:
Could not read the template: open templates/home.html: no such file or directory
Run this program from its own folder, and check the template.
Naming the file from go-work fails earlier, for the
reason in Chapter 25. It stops with an error containing
package guestbookapp/guestbook is not in std.
The starter runs as it is. main.go is finished, so read
it first. The page shows the form and a TODO line where the
messages go. Sending the form gets status 400, with the line
TODO: NewMessage is not written yet. on the page. The TODOs
are in guestbook/guestbook.go and
templates/home.html. Run go vet ./... in the
same folder to check both packages.
What to build it from
main.go knows about the web and nothing about the rules.
The package guestbook knows about the rules and the file,
and nothing about the web. It could be used by a terminal program with
no change. Here is finished/main.go, which the starter also
has:
// Guestbook -- main (finished).
//
// Run from this folder, because the template and the data file are relative to
// it:
// cd projects/06-guestbook/finished
// go run .
package main
import (
"bytes"
"fmt"
"html/template"
"net/http"
"guestbookapp/guestbook"
)
const templateFile = "templates/home.html"
const dataFile = "messages.json"
// homeData is everything the template needs.
type homeData struct {
Error string
Name string
Text string
Messages []guestbook.Message
}
var homeTemplate *template.Template
var book *guestbook.Book
// showHome builds the page in memory, and sends it only if that worked.
func showHome(w http.ResponseWriter, status int, data homeData) {
data.Messages = book.Messages()
var page bytes.Buffer
err := homeTemplate.Execute(&page, data)
if err != nil {
fmt.Println("Template error:", err)
http.Error(w, "The page could not be built.", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
page.WriteTo(w)
}
func handleHome(w http.ResponseWriter, r *http.Request) {
showHome(w, http.StatusOK, homeData{})
}
func handleAdd(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
text := r.FormValue("text")
message, err := guestbook.NewMessage(name, text)
if err != nil {
// Show the form again, with what the visitor typed.
data := homeData{Error: err.Error(), Name: name, Text: text}
showHome(w, http.StatusBadRequest, data)
return
}
err = book.Add(message)
if err != nil {
fmt.Println("Save error:", err)
http.Error(w, "The message could not be saved.", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func main() {
parsed, err := template.ParseFiles(templateFile)
if err != nil {
fmt.Println("Could not read the template:", err)
fmt.Println("Run this program from its own folder, and check the template.")
return
}
homeTemplate = parsed
opened, err := guestbook.Open(dataFile)
if err != nil {
fmt.Println("Could not load the messages:", err)
fmt.Println("Fix or remove the file, then start the server again.")
return
}
book = opened
http.HandleFunc("GET /{$}", handleHome)
http.HandleFunc("POST /messages", handleAdd)
fmt.Println("Guestbook running at http://localhost:8080")
fmt.Println("Press Ctrl+C to stop it.")
err = http.ListenAndServe("localhost:8080", nil)
if err != nil {
fmt.Println("Server stopped:", err)
}
}The package guestbook needs:
Message, a struct with the fieldsNameandText.NewMessage(name, text), which trims, checks, and returns aMessageand an error. Its error messages are written for visitors, becausemain.goshows them on the page.Book, a struct with async.Mutex, the file name, and a slice of messages.Open(filename), which returns a*Bookand an error, with a missing file giving an empty book.(b *Book) Messages(), which returns a copy of the messages.(b *Book) Add(message), which saves first and keeps the new slice only if saving worked.
Hints
- The mutex is now a field of
Book, next to the data it protects, and you writeb.mu.Lock(). The methods need pointer receivers. With a value receiver, each call would copy the lock, andgo vetreportsMessages passes lock by value. Messagehas exported fields, which breaks the advice in Chapter 30 a little.encoding/jsonandhtml/templatecan only see exported fields, so there is no choice.Bookkeeps its fields private, so the slice can be reached only through its methods, and that is where the rules are.NewMessagedoes not touch the book, so it needs no lock.main.gocalls it first, which is how it can tell a visitor's mistake (400) from a problem with the disk (500).- Count characters with
utf8.RuneCountInString, so that a name in another alphabet is not measured in bytes. - In the template,
{{range .Messages}}has{{else}}for the empty case. A message in a<p>would lose its line endings, so the template's small<style>block setswhite-space: pre-wrapon the classtext, which keeps them. Everything is in the page itself, with no outside stylesheet. - The form's values go back into the page on an error. That is safe, because the template escapes them there too.
A bigger challenge (optional)
- Newest first. Use
slices.Reverseon the copy inMessages. - A limit. Refuse new messages once the book holds 1000, since the whole file is rewritten on every save.
- A count. Show
3 messagesat the top, using{{len .Messages}}in the template. - A package test by hand. Write a small terminal
program that imports
guestbook, to show that the package does not need the web.
None of these are required to finish the project.
Done?
When a message survives a restart, an empty form gets a useful error,
reloading the page after sending does not send the message twice, and
<script> shows up as text, your guestbook is done.
That is the last of the three programs that Chapter 01 promised. The
last project is the Web
Scoreboard, which brings the whole book together.