Processing forms
We’re going to add an HTML form for creating new snippets. The form will look a bit like this:
The high-level flow for processing this form will follow a standard Post-Redirect-Get pattern and will work like so:
1. The user is shown the blank form when they make a GET request to /snippet/create .
2. The user completes the form and it’s submitted to the server via a POST request to /snippet/create .
3. The form data will be validated by our snippetCreatePost handler. If there are any validation failures the form will be re-displayed with the appropriate form fields highlighted. If it passes our validation checks, the data for the new snippet will be added to the database and then we’ll redirect the user to GET /snippet/view/{id} .
Setting up an HTML form
Let’s begin by making a new ui/html/pages/create.html file to hold the HTML for the form.
{{define "title"}}Create a new Snippet{{end}} {{define "main"}} <form action="/snippet/create" method="POST"> <div> <label>Title:</label> <input type="text" name="title"> </div> <div> <label>Content:</label> <textarea name="content"></textarea> </div> <div> <label>Delete in:</label> <input type="radio" name="expires" value="365" checked> One Year <input type="radio" name="expires" value="7"> One Week <input type="radio" name="expires" value="1"> One Day </div> <div> <input type="submit" value="Publish snippet"> </div> </form> {{end}}
Now let’s add a new ‘Create snippet’ link to the navigation bar for our application, so that clicking it will take the user to this new form.
{{define "nav"}} <nav> <a href="/">Home</a> <a href="/snippet/create">Create snippet</a> </nav> {{end}}
And finally, we need to update the snippetCreate handler so that it renders our new page like so:
func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r) app.render(w, r, http.StatusOK, "create.html", data) }
Parsing form data
At a high-level, we can break this down into two distinct steps.
1. First, we need to use the r.ParseForm() method to parse the request body. This checks that the request body is well-formed, and then stores the form data in the request’s r.PostForm map. If there are any errors encountered when parsing the body (like there is no body, or it’s too large to process) then it will return an error. The r.ParseForm() method is also idempotent; it can safely be called multiple times on the same request without any side-effects.
2. We can then get to the form data contained in r.PostForm by using the r.PostForm.Get() method. For example, we can retrieve the value of the title field with r.PostForm.Get("title") . If there is no matching field name in the form this will return the empty string "" .
Open your cmd/web/handlers.go file and update it to include the following code:
func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) { // First we call r.ParseForm() which adds any data in POST request bodies // to the r.PostForm map. This also works in the same way for PUT and // PATCH requests. If there are any errors, we use our app.ClientError() // helper to send a 400 Bad Request response to the user. err := r.ParseForm() if err != nil { app.clientError(w, http.StatusBadRequest) return } // Use the r.PostForm.Get() method to retrive the title and content from // the r.PostForm map. title := r.PostForm.Get("title") content := r.PostForm.Get("content") // The r.PostForm.Get() method always returns the form data as a *string*. // However, we're expecting our expires value to be a number, and want to // represent it in our Go code as an integer. So we need to manually // convert the form data to an integer using strconv.Atoi(), and we send // a 400 Bad Request response if the conversion fails. expires, err := strconv.Atoi(r.PostForm.Get("expires")) if err != nil { app.clientError(w, http.StatusBadRequest) return } id, err := app.snippet.Insert(title, content, expires) if err != nil { app.serverError(w, r, err) return } http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }
The PostFormValue method
The net/http package also provides the r.PostFormValue() method, which is essentially a shortcut function that calls r.ParseForm() for you and then fetches the appropriate field value from r.PostForm .
I recommend avoiding this shortcut because it silently ignores any errors returned by r.ParseForm() . If you use it, it means that the parsing step could be encountering errors and failing for users, but there’s no feedback mechanism to let them (or you) know about the problem.
Multiple-value fields
Strictly speaking, the r.PostForm.Get() method that we’ve used in this chapter only returns the first value for a specific form field. This means you can’t use it with form fields which potentially send multiple values, such as a group of checkboxes.
<input type="checkbox" name="items" value="foo"> Foo <input type="checkbox" name="items" value="bar"> Bar <input type="checkbox" name="items" value="baz"> Baz
In this case you’ll need to work with the r.PostForm map directly. The underlying type of the r.PostForm map is url.Values , which in turn has the underlying type map[string][]string . So, for fields with multiple values you can loop over the underlying map to access them like so:
for i, item := range r.PostForm["items"] { fmt.Fprintf(w, "%d: Item %s\n", i, item) }
Limiting form size
By default, forms submitted with a POST method have a size limit of 10MB of data. The exception to this is if your form has the enctype="multipart/form-data" attribute and is sending multipart data, in which case there is no default limit. If you want to change the 10MB limit, you can use the http.MaxBytesReader() function like so:
// Limit the request body size to 4096 bytes r.Body = http.MaxBytesReader(w, r.Body, 4096) err := r.ParseForm() if err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) return }
With this code only the first 4096 bytes of the request body will be read during r.ParseForm() . Trying to read beyond this limit will cause the MaxBytesReader to return an error, which will subsequently be surfaced by r.ParseForm() .
Additionally — if the limit is hit — MaxBytesReader sets a flag on http.ResponseWriter which instructs the server to close the underlying TCP connection.
Query string parameters
If you have a form that submits data using the HTTP method GET , rather than POST , the form data will be included as URL query string parameters. For example, if you have a HTML form that looks like this:
<form action='/foo/bar' method='GET'> <input type='text' name='title'> <input type='text' name='content'> <input type='submit' value='Submit'> </form>
When the form is submitted, it will send a GET request with a URL that looks like this: /foo/bar?title=value&content=value .
You can retrieve the values for the query string parameters in your handlers via the r.URL.Query().Get() method. This will always return a string value for a parameter, or the empty string "" if no matching parameter exists. For example:
func exampleHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Query().Get("title") content := r.URL.Query().Get("content") ... }
The r.Form map
An alternative way to access query string parameters is via the r.Form map. This is similar to the r.PostForm map that we’ve used in this chapter, except that it contains the form data from any POST request body and any query string parameters.
Let’s say that you have some code in your handler that looks like this:
err := r.ParseForm() if err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) return } title := r.Form.Get("title")
In this code, the line r.Form.Get("title") will return the title value from the POST request body or from a query string parameter with the name title . In the event of a conflict, the request body value will take precedent over the query string parameter.
Using r.Form can be very helpful if you want your application to be agnostic about how data values are passed to it. But outside of that scenario, r.Form doesn’t offer any benefits and it is clearer and more explicit to read data from the POST request body via r.PostForm or from query string parameters via r.URL.Query().Get() .
Validating form data
- Check that the title and content fields are not empty.
- Check that the title field is not more than 100 characters long.
- Check that the expires value exactly matches one of our permitted values ( 1 , 7 or 365 days).
Open up your handlers.go file and update the snippetCreatePost handler to include the appropriate validation rules like so:
func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { app.clientError(w, http.StatusBadRequest) return } title := r.PostForm.Get("title") content := r.PostForm.Get("content") expires, err := strconv.Atoi(r.PostForm.Get("expires")) if err != nil { app.clientError(w, http.StatusBadRequest) return } // Initialize a map to hold any validation errors for the form fields. fieldErrors := make(map[string]string) // Check that the title value is not blank and is not more than 100 // characters long. If it fails either of those checks, add a message to // the errors map using the field name as the key. if strings.TrimSpace(title) == "" { fieldErrors["title"] = "This field cannot be blank." } else if utf8.RuneCountInString(title) > 100 { fieldErrors["title"] = "This field cannot be more than 100 characters long." } // Check that the content value isn't blank. if strings.TrimSpace(content) == "" { fieldErrors["content"] = "This field cannot be blank." } // Check the expires value matches one of the permitted values // (1, 7 or 365). if expires != 1 && expires != 7 && expires != 365 { fieldErrors["expires"] = "This field must equal 1, 7 or 365." } // If there are any errors, dump them in a plain text HTTP response and // return from the handler. if len(fieldErrors) > 0 { fmt.Fprint(w, fieldErrors) return } id, err := app.snippet.Insert(title, content, expires) if err != nil { app.serverError(w, r, err) return } http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }
Displaying errors and repopulating fields
Now that the snippetCreatePost handler is validating the data, the next stage is to manage these validation errors gracefully.
If there are any validation errors, we want to re-display the HTML form, highlighting the fields which failed validation and automatically re-populating any previously submitted data (so that the user doesn’t need to enter it again).
To do this, let’s begin by adding a new Form field to our templateData struct:
type templateData struct { CurrentYear int Snippet models.Snippet Snippets []models.Snippet Form any }
We’ll use this Form field to pass the validation errors and previously submitted data back to the template when we re-display the form.
Next let’s head back to our cmd/web/handlers.go file and define a new snippetCreateForm struct to hold the form data and any validation errors, and update our snippetCreatePost handler to use this.
// Define a snippetCreateForm struct o represent the form data and validation // errors for the form fields. Note that all the struct fields are // deliberately exported. This is because struct fields must be exported in // order to be read by the html/template package when rendering the template. type snippetCreateForm struct { Title string Content string Expires int FieldErrors map[string]string } func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { app.clientError(w, http.StatusBadRequest) return } // Get the expires value from the form as normal. expires, err := strconv.Atoi(r.PostForm.Get("expires")) if err != nil { app.clientError(w, http.StatusBadRequest) return } // Create an instance of the snippetCreateForm struct containing the values // from the form and an empty map for any validation errors. form := snippetCreateForm{ Title: r.PostForm.Get("title"), Content: r.PostForm.Get("content"), Expires: expires, FieldErrors: map[string]string{}, } // Update the validation checks so that they operate on the // snippetCreateForm instance. if strings.TrimSpace(form.Title) == "" { form.FieldErrors["title"] = "This field cannot be blank." } else if utf8.RuneCountInString(form.Title) > 100 { form.FieldErrors["title"] = "This field cannot be more than 100 characters long." } if strings.TrimSpace(form.Content) == "" { form.FieldErrors["content"] = "This field cannot be blank." } if form.Expires != 1 && form.Expires != 7 && form.Expires != 365 { form.FieldErrors["expires"] = "This field must equal 1, 7 or 365." } // If there are any validation errors, re-display the create.html template, // passing in the snippetCreateForm instance as dynamic data in the Form // field. Note that we use the HTTP status code 422 Unprocessable Entity // when sending the response to indicate that there was a validation error. if len(form.FieldErrors) > 0 { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "create.html", data) return } id, err := app.snippet.Insert(form.Title, form.Content, form.Expires) if err != nil { app.serverError(w, r, err) return } http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }
Updating the HTML template
The next thing that we need to do is update our create.html template to display the validation errors and re-populate any previous data.
Re-populating the form data is straightforward enough — we should be able to render this in the templates using tags like {{.Form.Title}} and {{.Form.Content}} , in the same way that we displayed the snippet data earlier in the book.
For the validation errors, the underlying type of our FieldErrors field is a map[string]string , which uses the form field names as keys. For maps, it’s possible to access the value for a given key by simply chaining the key name. So, for example, to render a validation error for the title field we can use the tag {{.Form.FieldErrors.title}} in our template.
{{define "title"}}Create a new Snippet{{end}} {{define "main"}} <form action="/snippet/create" method="POST"> <div> <label>Title:</label> <!-- Uset the `with` action to render the value of .Form.FieldErrors.title if it is not empty. --> {{with .Form.FieldErrors.title}} <label class="error">{{.}}</label> {{end}} <!-- Repopulate the title data by setting the `value` atribute. --> <input type="text" name="title" value="{{.Form.Title}}"> </div> <div> <label>Content:</label> {{with .Form.FieldErrors.content}} <label class="error">{{.}}</label> {{end}} <textarea name="content">{{.Form.Content}}</textarea> </div> <div> <label>Delete in:</label> {{with .Form.FieldErrors.expires}} <label class="error">{{.}}</label> {{end}} <!-- Here we use `if` action to check if the value of the re-populated expires field equals 365. If it does, we render the `checked` attribute so that the radio input is re-selected. --> <input type="radio" name="expires" value="365" {{if (eq .Form.Expires 365)}}checked{{end}}> One Year <input type="radio" name="expires" value="7" {{if (eq .Form.Expires 7)}}checked{{end}}> One Week <input type="radio" name="expires" value="1" {{if (eq .Form.Expires 1)}}checked{{end}}> One Day </div> <div> <input type="submit" value="Publish snippet"> </div> </form> {{end}}
There’s one final thing we need to do. If we tried to run the application now, we would get a 500 Internal Server Error when we first visit the form at http://localhost:4000/snippet/create . This is because our snippetCreate handler currently doesn’t set a value for the templateData.Form field, meaning that when Go tries to evaluate a template tag like {{with .Form.FieldErrors.title}} it would result in an error because Form is nil .
Let’s fix that by updating our snippetCreate handler so that it initializes a new createSnippetForm instance and passes it to the template, like so:
func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r) // Initialize a new createSnippetForm instance and pass it to the template. // Notice how this is also a great opportunity to set any default or // 'initial' values for the form. data.Form = snippetCreateForm{ Expires: 365, } app.render(w, r, http.StatusOK, "create.html", data) }
Creating validation helpers
Adding a validator package
Then in this new internal/validator/validator.go file add the following code:
package validator import ( "slices" "strings" "unicode/utf8" ) // Validator contains a map of validation error messages for our form fields. type Validator struct { FieldErrors map[string]string } // Valid returns true if the FieldErrors map doesn't contain any entries. func (v *Validator) Valid() bool { return len(v.FieldErrors) == 0 } // AddFieldError adds an error message to the FieldErrors map (so long as // no entry already exists for the given key). func (v *Validator) AddFieldError(key, message string) { // Note: We need to initialize the map first if it isn't already // initialized. if v.FieldErrors == nil { v.FieldErrors = make(map[string]string) } if _, exists := v.FieldErrors[key]; !exists { v.FieldErrors[key] = message } } // CheckField adds an error message to the FieldErrors map only if a // validation check is not `ok`. func (v *Validator) CheckField(ok bool, key string, message string) { if !ok { v.AddFieldError(key, message) } } // NotEmpty returns true if a value is not an empty string. func NotEmpty(value string) bool { return strings.TrimSpace(value) != "" } // MaxChars returns true if a value contains no more than n characters. func MaxChars(value string, n int) bool { return utf8.RuneCountInString(value) <= n } // PermittedValue returns true if a value is in a list of specific permitted // values. func PermittedValue[T comparable](value T, permittedValues ...T) bool { return slices.Contains(permittedValues, value) }
To summarize this:
In the code above we’ve defined a Validator struct type which contains a map of error messages. The Validator type provides a CheckField() method for conditionally adding errors to the map, and a Valid() method which returns whether the errors map is empty or not. We’ve also added NotBlank() , MaxChars() and PermittedValue() functions to help us perform some specific validation checks.
Note: the PermittedValue() function is a generic function which will work with values of different types.
Using the helpers
We’ll head back to our cmd/web/handlers.go file and update it to embed a Validator struct in our snippetCreateForm struct, and then use this to perform the necessary validation checks on the form data.
// Remove the explicit FieldErrors field and instead embed the Validator // struct. Embedding this means that our snippetCreateForm "inherits" all the // fields and methods of our Validator struct (including the FieldErrors // field). type snippetCreateForm struct { Title string Content string Expires int validator.Validator } func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { app.clientError(w, http.StatusBadRequest) return } expires, err := strconv.Atoi(r.PostForm.Get("expires")) if err != nil { app.clientError(w, http.StatusBadRequest) return } form := snippetCreateForm{ Title: r.PostForm.Get("title"), Content: r.PostForm.Get("content"), Expires: expires, // Remove the FieldErrors assignment from here. } // Because the Validator struct is embedded by the snippetCreateForm // struct, we can call CheckField() directly on it to execute our // validation checks. CheckField() will add the provided key and error // message to the FieldErrors map if the check does not evaluate to true. form.CheckField(validator.NotEmpty(form.Title), "title", "This field cannot be empty.") form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long.") form.CheckField(validator.NotEmpty(form.Content), "content", "This field cannot be empty.") form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7, or 365.") // Use the Valid() method to see if any of the checks failed. If they did, // then re-render the template passing in the form in the same way as // before. if !form.Valid() { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "create.html", data) return } id, err := app.snippet.Insert(form.Title, form.Content, form.Expires) if err != nil { app.serverError(w, r, err) return } http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }
Automatic form parsing
We can simplify our snippetCreatePost handler further by using a third-party package like go-playground/form or gorilla/schema to automatically decode the form data into the createSnippetForm struct. Using an automatic decoder is totally optional, but it can help to save you time and typing — especially if your application has lots of forms, or you need to process a very large form.
zzh@ZZHPC:/zdata/Github/snippetbox$ go get github.com/go-playground/form/v4 go: downloading github.com/go-playground/form/v4 v4.2.1 go: added github.com/go-playground/form/v4 v4.2.1
Using the form decoder
To get this working, the first thing that we need to do is initialize a new *form.Decoder instance in our main.go file and make it available to our handlers as a dependency. Like this:
type application struct { logger *slog.Logger snippet *models.SnippetModel templateCache map[string]*template.Template formDecoder *form.Decoder } func main() { addr := flag.String("addr", ":4000", "HTTP network address") dbDriver := flag.String("dbdriver", "mysql", "Database driver name") dsn := flag.String("dsn", "zeb:zebpwd@tcp(localhost:3306)/snippetbox?parseTime=true", "MySQL data source name") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) db, err := openDB(*dbDriver, *dsn) if err != nil { logger.Error(err.Error()) os.Exit(1) } defer db.Close() templateCache, err := newTemplateCache() if err != nil { logger.Error(err.Error()) os.Exit(1) } // Initialize a form decoder instance. formDecoder := form.NewDecoder() // Add it to the application dependencies. app := &application{ logger: logger, snippet: &models.SnippetModel{DB: db}, templateCache: templateCache, formDecoder: formDecoder, } logger.Info("starting server", "addr", *addr) err = http.ListenAndServe(*addr, app.routes()) logger.Error(err.Error()) os.Exit(1) }
Next let’s go to our cmd/web/handlers.go file and update it to use this new decoder, like so:
// Update our snippetCreateForm struct to include struct tags which tell the decoder how to map // HTML form values into the different struct fields. So, for example, here we're telling the // decoder to store the value from the HTML form input with the name "title" in the Title field. // The struct tag `form:"-"` tells the decoder to completely ignore a field during decoding. type snippetCreateForm struct { Title string `form:"title"` Content string `form:"content"` Expires int `form:"expires"` validator.Validator `form:"-"` } func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { app.clientError(w, http.StatusBadRequest) return } // Declare a new empty instance of the snippetCreateForm struct. var form snippetCreateForm // Call the Decode() method of the form decoder, passing in the current request and // *a pointer* to our snippetCreateForm struct. This will essentially fill our struct with // the relevant values from the HTML form. If there is a problem, we return a 400 Bad Request // response to the client. err = app.formDecoder.Decode(&form, r.PostForm) if err != nil { app.clientError(w, http.StatusBadRequest) return } // Then validate and use the data as normal... form.CheckField(validator.NotEmpty(form.Title), "title", "This field cannot be empty.") form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long.") form.CheckField(validator.NotEmpty(form.Content), "content", "This field cannot be empty.") form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7, or 365.") if !form.Valid() { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "create.html", data) return } id, err := app.snippet.Insert(form.Title, form.Content, form.Expires) if err != nil { app.serverError(w, r, err) return } http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }
Hopefully you can see the benefit of this pattern. We can use simple struct tags to define a mapping between our HTML form and the ‘destination’ struct fields, and unpacking the form data to the destination now only requires us to write a few lines of code — irrespective of how large the form is.
Importantly, type conversions are handled automatically too. We can see that in the code above, where the expires value is automatically mapped to an int data type.
So that’s really good. But there is one problem.
When we call app.formDecoder.Decode() it requires a non-nil pointer as the target decode destination. If we try to pass in something that isn’t a non-nil pointer, then Decode() will return a form.InvalidDecoderError error.
If this ever happens, it’s a critical problem with our application code (rather than a client error due to bad input). So we need to check for this error specifically and manage it as a special case, rather than just returning a 400 Bad Request response.
Creating a decodePostForm helper
To assist with this, let’s create a new decodePostForm() helper which does three things:
- Calls r.ParseForm() on the current request.
- Calls app.formDecoder.Decode() to unpack the HTML form data to a target destination.
- Checks for a form.InvalidDecoderError error and triggers a panic if we ever see it.
// The second parameter dst is the target destination that we want to decode the form data into. func (app *application) decodePostForm(r *http.Request, dst any) error { // Call ParseForm() on the request err := r.ParseForm() if err != nil { return err } // Call Decode() on our decoder instance, passing the target destination as the first // parameter. err = app.formDecoder.Decode(dst, r.PostForm) if err != nil { // If we try to use an invalid target destination, the Decode() method will return an // error with the type *form.InvalidDecoderError. We use errors.As() to check for this // and raise a panic rather than returning the error. var invalidDecodeError *form.InvalidDecoderError if errors.As(err, &invalidDecodeError) { panic(err) } // For all other errors, we return them as normal. return err } return nil }
And with that done, we can make the final simplification to our snippeCreatePost handler. Go ahead and update it to use the decodePostForm() helper and remove the r.ParseForm() call, so that the code looks like this:
func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) { var form snippetCreateForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.CheckField(validator.NotEmpty(form.Title), "title", "This field cannot be empty.") form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long.") form.CheckField(validator.NotEmpty(form.Content), "content", "This field cannot be empty.") form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7, or 365.") if !form.Valid() { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "create.html", data) return } id, err := app.snippet.Insert(form.Title, form.Content, form.Expires) if err != nil { app.serverError(w, r, err) return } http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }
That’s looking really good.
Our handler code is now nice and succinct, but still very clear in terms of it’s behavior and what it is doing. And we have a general pattern in place for form processing and validation that we can easily re-use on other forms in our project — such as the user signup and login forms that we’ll build shortly.