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 snippetCreateForm 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.
In this chapter, we’ll look at how to use the go-playground/form package. If you’re following along, please go ahead and install it like so:
$ go get github.com/go-playground/form/v4@v4 go get: 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. Here’s how:
package main import ( "database/sql" "flag" "html/template" "log/slog" "net/http" "os" "snippetbox.alexedwards.net/internal/models" "github.com/go-playground/form/v4" // New import _ "github.com/go-sql-driver/mysql" ) // Add a formDecoder field to hold a pointer to a form.Decoder instance. type application struct { logger *slog.Logger snippets *models.SnippetModel templateCache map[string]*template.Template formDecoder *form.Decoder } func main() { addr := flag.String("addr", ":4000", "HTTP network address") dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) db, err := openDB(*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 decoder instance... formDecoder := form.NewDecoder() // And add it to the application dependencies. app := &application{ logger: logger, snippets: &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:
package main ... // 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.NotBlank(form.Title), "title", "This field cannot be blank") form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long") form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank") 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.tmpl", data) return } id, err := app.snippets.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 — no matter 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 bug in our application code (rather than a client error because of 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.InvalidDecoderErrorerror and triggers a panic if we ever see it.
If you’re following along, please go ahead and add this to your cmd/web/helpers.go file like so:
package main import ( "bytes" "errors" // New import "fmt" "net/http" "time" "github.com/go-playground/form/v4" // New import ) ... // Create a new decodePostForm() helper method. The second parameter here, dst, // is the target destination into which we want to decode the form data. func (app *application) decodePostForm(r *http.Request, dst any) error { // Call ParseForm() on the request, in the same way that we did in our // snippetCreatePost handler. 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 panic. At the end of this chapter // we'll talk about panicking versus returning errors, and discuss why // it's an appropriate thing to do in this specific situation. var invalidDecoderError *form.InvalidDecoderError if errors.As(err, &invalidDecoderError) { panic(err) } // For all other errors, return them as normal. return err } return nil }
And with that done, we can make the final simplification to our snippetCreatePost handler. Go ahead and update it to use the decodePostForm() helper and remove the r.ParseForm() call, so that the code looks like this:
package main ... 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.NotBlank(form.Title), "title", "This field cannot be blank") form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long") form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank") 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.tmpl", data) return } id, err := app.snippets.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 its behavior and what it is doing. And we have a general pattern in place for form processing and validation that we can easily reuse on other forms in our project — such as the user signup and login forms that we’ll build shortly.
Additional information
Panicking vs returning errors
The decision to panic in the decodePostForm() helper if we get a form.InvalidDecoderError error isn’t taken lightly. As you’re probably aware, it’s generally considered best practice in Go to return your errors and handle them gracefully.
But — in some specific circumstances — it can be OK to panic. And you shouldn’t be too dogmatic about not panicking when it makes sense to.
It’s helpful here to distinguish between the two classes of error that your application might encounter.
The first class of errors are operational errors that may occur during normal operation. Some examples of operational errors are those caused by a database query timeout, a network resource being unavailable, or bad user input. These errors don’t necessarily mean there is a problem with your program itself — in fact they’re often caused by things outside the control of your program. Almost all of the time it’s good practice to return these kinds of errors and handle them gracefully.
The other class of errors are programmer errors. These are errors which should not happen during normal operation, and if they do it is probably the result of a developer mistake or a logical error in your codebase. These errors are truly exceptional, and using panic in these circumstances is more widely accepted. In fact, the Go standard library frequently does this when you make a logical error or try to use the language features in an unintended way — such as when trying to access an out-of-bounds index in a slice, or trying to close an already-closed channel.
But even then, I’d recommend trying to return and gracefully handle programmer errors in most cases. The exception to this is when returning the error adds an unacceptable amount of error handling to the rest of your codebase.
Bringing this back to our decodePostForm() helper, if we get a form.InvalidDecoderError at runtime it’s because we as the developers have tried to use something that isn’t a non-nil pointer as the target decode destination. This is firmly a programmer error which we shouldn’t see under normal operation, and is something that should be picked up in development and tests long before deployment.
If we did return this error, rather than panicking, we would need to introduce additional code to manage it in each of our handlers — which doesn’t seem like a good trade-off for an error that we’re unlikely to ever see in production.
The Go By Example page on panics summarizes all of this quite nicely:
A panic typically means something went unexpectedly wrong. Mostly we use it to fail fast on errors that shouldn’t occur during normal operation and that we aren’t prepared to handle gracefully.