From 5e33a8a44c061080793ac0cf19095ef6e01f5892 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sat, 14 Jun 2025 15:43:01 -0400 Subject: [PATCH] Validating JSON input --- cmd/api/errors.go | 6 +++ cmd/api/handlers.go | 15 +++++++ internal/validator/validator.go | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 internal/validator/validator.go diff --git a/cmd/api/errors.go b/cmd/api/errors.go index 2477d9e..a8fc9d9 100644 --- a/cmd/api/errors.go +++ b/cmd/api/errors.go @@ -58,3 +58,9 @@ func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http. func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { app.errorResponse(w, r, http.StatusBadRequest, err.Error()) } + +// Note that the errors parameter here has the type map[string]string, which is exactly +// the same as the errors map contained in our Validator type. +func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { + app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) +} diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index 5d357a0..2ec7ee2 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -7,6 +7,7 @@ import ( "time" "git.runcible.io/learning/pulley/internal/data" + "git.runcible.io/learning/pulley/internal/validator" "github.com/julienschmidt/httprouter" ) @@ -34,6 +35,20 @@ func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Reques return } + // use intermediate struct for decoding, use Movie struct for validation + m := &data.Movie{ + Title: input.Title, + Year: input.Year, + Runtime: input.Runtime, + Genres: input.Genres, + } + + v := validator.New() + + if validator.ValidateMovie(v, m); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + } + // TODO save to DB // Dump the contents of the input struct in a HTTP response diff --git a/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 0000000..efe4460 --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,80 @@ +package validator + +import ( + "git.runcible.io/learning/pulley/internal/data" + "regexp" + "slices" + "time" +) + +// Declare a regular expression for sanity checking the format of email addresses (we'll +// use this later in the book). If you're interested, this regular expression pattern is +// taken from https://html.spec.whatwg.org/#valid-e-mail-address. +var ( + EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") +) + +type Validator struct { + Errors map[string]string +} + +// New is a helper which creates a new Validator instance with an empty errors map. +func New() *Validator { + return &Validator{Errors: make(map[string]string)} +} + +func (v *Validator) Valid() bool { + return len(v.Errors) == 0 +} + +// AddError adds an error message to the map (so long as no entry already exists for +// the given key). +func (v *Validator) AddError(key, message string) { + if _, exists := v.Errors[key]; !exists { + v.Errors[key] = message + } +} + +// Check adds an error message to the map only if a validation check is not 'ok'. +func (v *Validator) Check(ok bool, key, message string) { + if !ok { + v.AddError(key, message) + } +} + +// Generic function which returns true if a specific value is in a list of permitted +// values. +func PermittedValue[T comparable](value T, permittedValues ...T) bool { + return slices.Contains(permittedValues, value) +} + +// Matches returns true if a string value matches a specific regexp pattern. +func Matches(value string, rx *regexp.Regexp) bool { + return rx.MatchString(value) +} + +// Generic function which returns true if all values in a slice are unique. +func Unique[T comparable](values []T) bool { + uniqueValues := make(map[T]bool) + + for _, value := range values { + uniqueValues[value] = true + } + + return len(values) == len(uniqueValues) +} + +func ValidateMovie(v *Validator, m *data.Movie) { + + v.Check(m.Title != "", "title", "must be provided") + v.Check(len(m.Title) <= 500, "title", "must not be more than 500 bytes long") + v.Check(m.Year != 0, "year", "must be provided") + v.Check(m.Year >= 1888, "year", "must be greater than 1888") + v.Check(m.Year <= int32(time.Now().Year()), "year", "must not be in the future") + v.Check(m.Runtime != 0, "runtime", "must be provided") + v.Check(m.Runtime > 0, "runtime", "must be a positive integer") + v.Check(m.Genres != nil, "genres", "must be provided") + v.Check(len(m.Genres) >= 1, "genres", "must contain one genre") + v.Check(len(m.Genres) <= 5, "genres", "must not contain more than 5 genres") + v.Check(Unique(m.Genres), "genres", "must not contain duplicate values") +}