Validating Forms With Gin
- 8 minutes read - 1508 wordsThis is the sixth in a series of articles about writing a small reading list app in Go for personal use.
When we created a form to add new books, there were a couple of data-validation issues that I said I would handle in a future article. The future is now!
As we saw in that article, Gin has support for easily binding form data to a struct. It also has a fairly rich set of form validation capabilities, provided by go-playground/validator. Let’s explore those now.
At the end of this article, you will:
- have better data validation for the form
- have better error reporting for data validation errors
Validation Issues to Fix
There are two issues with how we’re doing form validation right now:
- There’s a manual check for empty title/author. Gin can do this for us instead.
- We’re allowing the user to specify the Book’s ID field. We can make Gin ignore this struct field so that we always auto-assign the ID.
The fixes are trivial. Just add to the field decorations in the struct:
type Book struct {
ID uint `form:"-"`
Title string `form:"title" binding:"required"`
Author string `form:"author" binding:"required"`
}
For the first issue, add binding:"required"
to the existing decorations.
This tells Gin that this field is
required
– it must not be an empty string.
For the second issue, adding form:"-"
tells Gin that it should not bind
this field. If a user sends an ID in the form data we will just ignore it.
In the bookNewPost
we can remove the manual validation that was
checking for empty title and author, and we also have to remove the
bind_error
that was passing a non-integer ID into the form.
Go ahead and make those changes, rebuild, and run the app. Browse to localhost:3000/books/new/ and click save without entering a title or author. It’s just a blank page… the app is returning a 400 status and no page body.
I’m not what you would call a User Experience expert, but this feels like what the experts call Really Bad Usability.
Providing Useful Error Messages
Form validation serves two purposes:
- It protects the app from bad data. This might be malicious, but it might just be accidental – like missing a digit from a phone number or misformatting an email address.
- It protects the user from making mistakes or forgetting to fill in a required field.
Good error messages aren’t necessary if all we want to do is protect the app, but we’d really like to help our users out and it’s not much extra work.
A common pattern that I use is to create an errors template, and then
include it in any of my pages that need it. Save this in
templates/base/errors.html
:
{{ define "base/errors.html" }}
{{ if .errors }}
{{ range .errors }}
<div>
{{ . }}
</div>
{{ end }}
{{ end }}
{{ end }}
As you can see, this simply ranges over the .errors
slice (if present)
and outputs each error message into the page. Next week when we deal with
stying we’ll make this look better.
Now modify templates/books/new.html
so that the top looks like this:
{{ define "books/new.html" }}
{{ template "base/header.html" . }}
{{ template "base/errors.html" . }}
This will pass along any variables provided to the new book template to the errors template.
Finally, we can update bookNewPost
to catch validation errors and
send them into the template:
func bookNewPost(c *gin.Context) {
book := &Book{}
if err := c.ShouldBind(book); err != nil {
verrs := err.(validator.ValidationErrors)
messages := make([]string, len(verrs))
for i, verr := range verrs {
messages[i] = fmt.Sprintf(
"%s is required, but was empty.",
verr.Field())
}
c.HTML(http.StatusBadRequest, "books/new.html",
gin.H{"errors": messages})
return
}
db := c.Value("database").(*gorm.DB)
if err := db.Create(&book).Error; err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.Redirect(http.StatusFound, "/books/")
}
As noted above, this removes the manual validation.
It also changes Bind
to ShouldBind
. The former will call
AbortWithError(400)
, whereas the latter will just return error. Since the
request isn’t aborted, we can set the status ourselves (we still use
StatusBadRequest
– 400) and show the user the page they submitted.
But not just the page they submitted – it will also contain error
messages. When ShouldBind
returns an error, it will be of type
ValidationErrors
,
which is just a slice of
FieldError
.
Since we want the page to show some error messages to the user, we need to
give the template an errors
variable. This variable has to be a slice
that can be displayed as strings. We could just pass verrs
directly,
because the error interface will render as strings. But the default strings
we get are cryptic at best, and we’re trying to give the user some
actionable feedback.
To provide that feedback, we create a string slice of the same length as
the number of validation errors, and then lovingly hand-craft an artisinal
error message for each verr
. Note that in general the error message
formatting will be more complex but for this simple handler there are only
two possible fields, and the only failure mode is that they are missing.
Setting gin.H{"errors": messages}
passes the message strings down into
the template. If you rebuild and rerun the app, and submit the new book
page with empty fields, you will now see those messages show up.
It compile and passes tests, and we’ve got 100% line-based coverage of the handler function. But we know that 100% coverage is not enough. One thing we’re missing in our tests is a check that the error messages are present in the body when our input to the form fails validation.
Here’s the new version of the whole test – it’s a little long but the changes from the previous version aren’t that big and are straightforward:
- as noted above, the
bind_error
case is removed - an extra
fragments
field is in the test case struct - the
empty
,empty_author
, andempty_title
test cases havefragments
populated - in the body of the test case loop, if
tc.fragments
is set, then it callsbodyHasFragments
func TestBookNewPost(t *testing.T) {
t.Parallel()
dropTable := func(t *testing.T, db *gorm.DB) {
err := db.Migrator().DropTable("books")
if err != nil {
t.Fatalf("error dropping table 'books': %s", err)
}
}
tcs := []struct {
name string
data gin.H
setup func(*testing.T, *gorm.DB)
status int
fragments []string
}{
{
name: "nominal",
data: gin.H{"title": "my book", "author": "me"},
status: http.StatusFound,
},
{
// This makes the field validation fail because the
// author is empty.
name: "empty_author",
data: gin.H{"title": "1"},
status: http.StatusBadRequest,
fragments: []string{
"Author is required, but was empty",
},
},
{
// This makes the field validation fail because the
// title is empty.
name: "empty_title",
data: gin.H{"author": "9"},
status: http.StatusBadRequest,
fragments: []string{
"Title is required, but was empty",
},
},
{
// This makes the field validation fail because both
// title and author are empty.
name: "empty",
data: gin.H{},
status: http.StatusBadRequest,
fragments: []string{
"Author is required, but was empty",
"Title is required, but was empty",
},
},
{
name: "db_error",
data: gin.H{"title": "a", "author": "b"},
setup: dropTable,
status: http.StatusInternalServerError,
},
}
for i := range tcs {
tc := &tcs[i]
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := freshDb(t)
if tc.setup != nil {
tc.setup(t, db)
}
w := postHasStatus(t, db, "/books/new", &tc.data,
tc.status)
if tc.fragments != nil {
body := w.Body.String()
bodyHasFragments(t, body, tc.fragments)
}
if tc.status == http.StatusFound {
// Make sure the record is in the db.
books := []Book{}
result := db.Find(&books)
if result.Error != nil {
t.Fatalf("error fetching books: %s", result.Error)
}
if result.RowsAffected != 1 {
t.Fatalf("expected 1 row affected, got %d",
result.RowsAffected)
}
if tc.data["title"] != books[0].Title {
t.Fatalf("expected title '%s', got '%s",
tc.data["title"], books[0].Title)
}
if tc.data["author"] != books[0].Author {
t.Fatalf("expected author '%s', got '%s",
tc.data["author"], books[0].Author)
}
// Check the redirect location.
url, err := w.Result().Location()
if err != nil {
t.Fatalf("location check error: %s", err)
}
if "/books/" != url.String() {
t.Errorf("expected location '/books/', got '%s'",
url.String())
}
}
})
}
}
We’ve really only scratched the surface of what’s possible for form
validation. validator
provides a lot of different format checks. For
example, if we wanted to track the ISBN of our books, there’s a validator
for
that.
Once you understand the basic mechanics of form validation, using other validators is simple. But you can also perform some pretty sophisticated checks. Our app doesn’t have anything that needs this level of checking yet, but I’ll cover this when we get to that point.
You can see this change on GitLab.
Next Week
Next week we’ll fix up the look of our app with a little CSS and some structural fixes. (Maybe even a navbar so you can actually find the New Book form.) Free bonus: embedding templates and CSS into the binary for single-file deployment.