diff --git a/go.mod b/go.mod index c8971a2..63341a0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module git.runcible.io/learning/ratchet go 1.23.3 require github.com/mattn/go-sqlite3 v1.14.24 + +require github.com/go-playground/form/v4 v4.2.1 // indirect diff --git a/go.sum b/go.sum index 9dcdc9b..25129c1 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,5 @@ +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw= +github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/internal/server/form.go b/internal/server/form.go index e8105ee..716b654 100644 --- a/internal/server/form.go +++ b/internal/server/form.go @@ -1,13 +1,20 @@ +// test package server +import "git.runcible.io/learning/ratchet/internal/validator" + // Define a snippetCreateForm struct to represent the form data and validation // errors for the form fields. Note that all the struct fields are deliberately // exported (i.e. start with a capital letter). This is because struct fields // must be exported in order to be read by the html/template package when // rendering the template. +// +// Remove the explicit FieldErrors struct 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 - FieldErrors map[string]string + Title string + Content string + Expires int + validator.Validator } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index c338ae3..acca6f9 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,3 +1,4 @@ +// test package server import ( @@ -6,10 +7,9 @@ import ( "log/slog" "net/http" "strconv" - "strings" - "unicode/utf8" "git.runcible.io/learning/ratchet/internal/model" + "git.runcible.io/learning/ratchet/internal/validator" ) // TODO function should accept and a pointer to an interface allowing for mocking in tests. @@ -187,32 +187,44 @@ func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, snippetServ // 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{}, + Title: r.PostForm.Get("title"), + Content: r.PostForm.Get("content"), + Expires: expires, } // VALIDATION - - if strings.TrimSpace(form.Title) == "" { - form.FieldErrors["title"] = "field cannot be blank" - // we want to count the number of unicode code points not bytes in the string - } else if utf8.RuneCountInString(form.Title) > 100 { - form.FieldErrors["title"] = "This field cannot exceed 100 characters" - } - if strings.TrimSpace(form.Content) == "" { - form.FieldErrors["content"] = "field cannot be blank" - } - if expires != 1 && expires != 7 && expires != 365 { - form.FieldErrors["expires"] = "This field must equal 1,7, or 365" - } + // THE OLD WAY + // if strings.TrimSpace(form.Title) == "" { + // form.FieldErrors["title"] = "field cannot be blank" + // // we want to count the number of unicode code points not bytes in the string + // } else if utf8.RuneCountInString(form.Title) > 100 { + // form.FieldErrors["title"] = "This field cannot exceed 100 characters" + // } + // if strings.TrimSpace(form.Content) == "" { + // form.FieldErrors["content"] = "field cannot be blank" + // } + // if expires != 1 && expires != 7 && expires != 365 { + // form.FieldErrors["expires"] = "This field must equal 1,7, or 365" + // } // If there are any validation errors, then re-display the create.tmpl 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 { + // if len(form.FieldErrors) > 0 { + // data := newTemplateData() + // data.Form = form + // renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "create.go.tmpl", data) + // return + // } + + // New VALIDATION + form.CheckField(validator.NotBlank(form.Title), "title", "this field cannot be blank") + form.CheckField(validator.MaxChars(form.Title, 100), "title", "this field cannot exceed 100 characters") + form.CheckField(validator.NotBlank(form.Content), "content", "this field cannot be blank") + form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "this field cannot be blank") + + if !form.Valid() { data := newTemplateData() data.Form = form renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "create.go.tmpl", data) diff --git a/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 0000000..7150f37 --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,54 @@ +// See also https://www.alexedwards.net/blog/validation-snippets-for-go +// for more validation snippets +package validator + +import ( + "slices" + "strings" + "unicode/utf8" +) + +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) { + 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, message string) { + if !ok { + v.AddFieldError(key, message) + } +} + +// NotBlank() returns true if a value is not an empty string. +func NotBlank(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) +}