Validating Input

Check incoming data at the edge of your program and return errors that name the field that was wrong.

Syntaxfunc (in Input) Validate() []FieldError

Validation is checking that data is usable before the rest of your program sees it. Do it at the boundary: in the handler, immediately after decoding. Everything further in can then assume the values make sense, which removes a defensive check from every function it passes through.

Collect, do not stop

A validator that returns on the first problem makes the client guess. Three bad fields become three round trips. Collect every problem into a slice and send them all at once. Label each one with the field name as it appears in the JSON, not the Go field name, so a form can attach the message to the right input box.

Where to put it

A Validate method on the request struct keeps the rules next to the shape they describe. The handler decodes, calls Validate, and on a non-empty result sends 422 with the list. The standard library already has the pieces: strings.TrimSpace to drop surrounding spaces, net/mail.ParseAddress for addresses, strconv.Atoi to read a number out of a URL, utf8.RuneCountInString to count characters rather than bytes.

Shorthand and longhand

errs := in.Validate() on one line and if len(errs) > 0 { on the next is the long form. if errs := in.Validate(); len(errs) > 0 { is the same two statements joined, with the call moved inside the if. The short form also confines errs to the if and its else.

What trips people up

Validate the decoded struct, not the raw text. A number sent as 0 and a number left out look identical once decoded.

Example

Example · go
package main

import (
	"fmt"
	"net/mail"
	"strings"
)

type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

type signup struct {
	Email string `json:"email"`
	Name  string `json:"name"`
	Age   int    `json:"age"`
}

// Validate reports every problem it finds, not just the first.
func (s signup) Validate() []FieldError {
	var errs []FieldError
	if _, err := mail.ParseAddress(s.Email); err != nil {
		errs = append(errs, FieldError{Field: "email", Message: "must be an email address"})
	}
	if strings.TrimSpace(s.Name) == "" {
		errs = append(errs, FieldError{Field: "name", Message: "is required"})
	}
	if s.Age < 13 || s.Age > 130 {
		errs = append(errs, FieldError{Field: "age", Message: "must be between 13 and 130"})
	}
	return errs
}

func main() {
	in := signup{Email: "nope", Age: 9}
	for _, e := range in.Validate() {
		fmt.Printf("%s: %s\n", e.Field, e.Message)
	}
}

// Output:
// email: must be an email address
// name: is required
// age: must be between 13 and 130

When to use it

  • Reject a signup with 422 and a per-field list so the browser form can highlight each bad input.
  • Normalise an email address to lowercase before checking whether it is already taken.
  • Confirm a PATCH request actually sent a field before overwriting a database column with a zero.

More examples

Sending the field errors back as 422

This is the whole boundary in one handler: decode failures are 400 because the request itself was malformed, validation failures are 422 because it parsed and the contents were unacceptable. The response shape stays the same for every endpoint, so a client can write one piece of code to display field errors.

Example · go
type errorBody struct {
	Message string       `json:"message"`
	Errors  []FieldError `json:"errors"`
}

func createSignup(w http.ResponseWriter, r *http.Request) {
	var in signup
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "malformed JSON", http.StatusBadRequest) // the bytes are wrong
		return
	}
	if errs := in.Validate(); len(errs) > 0 {
		body := errorBody{Message: "validation failed", Errors: errs}
		buf, err := json.Marshal(body)
		if err != nil {
			http.Error(w, "server error", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusUnprocessableEntity) // the values are wrong
		_, _ = w.Write(buf)
		return
	}
	// in is trustworthy from here down.
}

Telling absent apart from zero

A PATCH endpoint has to know which fields the client meant to change. With a plain int you cannot tell "set pages to 0" from "leave pages alone", and you will either reject valid zeros or silently overwrite rows. Pointer fields cost an extra nil check per field and buy you that distinction.

Example · go
type patchBook struct {
	// A pointer is nil when the key was absent from the JSON, and points
	// at a value when the key was present, even if that value is 0 or "".
	Title *string `json:"title"`
	Pages *int    `json:"pages"`
}

func (p patchBook) Validate() []FieldError {
	var errs []FieldError
	if p.Title == nil && p.Pages == nil {
		errs = append(errs, FieldError{Field: "", Message: "send at least one field"})
	}
	if p.Title != nil && strings.TrimSpace(*p.Title) == "" {
		errs = append(errs, FieldError{Field: "title", Message: "must not be blank"})
	}
	if p.Pages != nil && *p.Pages < 1 {
		errs = append(errs, FieldError{Field: "pages", Message: "must be at least 1"})
	}
	return errs
}

Normalise first, then validate

Trimming and lowercasing before the rules run means " [email protected] " is accepted and stored once, rather than creating a second account that differs only in case. The second function is a reminder that URL segments and query strings are input too: strconv.Atoi returns an error for anything that is not a whole number, and r.PathValue reads a wildcard from the route pattern.

Example · go
// Normalise mutates the value into its canonical form before any rule runs.
// It takes a pointer receiver because it changes the struct it is called on.
func (s *signup) Normalise() {
	s.Name = strings.TrimSpace(s.Name)
	s.Email = strings.ToLower(strings.TrimSpace(s.Email))
}

// Path parameters arrive as text and need the same treatment.
func bookID(r *http.Request) (int, []FieldError) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil || id < 1 {
		return 0, []FieldError{{Field: "id", Message: "must be a positive whole number"}}
	}
	return id, nil
}

Discussion

  • Be the first to comment on this lesson.