diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index f139c6a..3ed835d 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -7,8 +7,22 @@ import ( "strconv" "snippetbox.tonidefez.net/internal/models" + "snippetbox.tonidefez.net/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. +type snippetCreateForm struct { + Title string + Content string + Expires int + FieldErrors map[string]string + validator.Validator +} + func (app *Application) Home(w http.ResponseWriter, r *http.Request) { w.Header().Add("Server", "Go") @@ -50,15 +64,60 @@ func (app *Application) SnippetView(w http.ResponseWriter, r *http.Request) { } func (app *Application) SnippetCreate(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Display a form for creating a new snippet...")) + data := app.NewTemplateData(r) + + data.Form = snippetCreateForm{ + Expires: 365, + } + + app.render(w, r, http.StatusOK, "create.tmpl", *data) } func (app *Application) SnippetCreatePost(w http.ResponseWriter, r *http.Request) { - title := "O snail" - content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa" - expires := 7 + err := r.ParseForm() + if err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + // Get the expires value from the form as normal. + expires, err := strconv.Atoi(r.PostForm.Get("expires")) + if err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + // 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, + } + + // Because the Validator struct is embedded by the snippetCreateForm struct, + // we can call CheckField() directly on it to execute our validation checks. + // CheckField() will add the provided key and error message to the + // FieldErrors map if the check does not evaluate to true. For example, in + // the first line here we "check that the form.Title field is not blank". In + // the second, we "check that the form.Title field has a maximum character + // length of 100" and so on. + 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") + + // Use the Valid() method to see if any of the checks failed. If they did, + // then re-render the template passing in the form in the same way as + // before. + 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(title, content, expires) + id, err := app.snippets.Insert(form.Title, form.Content, form.Expires) if err != nil { app.serverError(w, r, err) return diff --git a/cmd/web/handlers_test.go b/cmd/web/handlers_test.go index a11e2fd..878ea1c 100644 --- a/cmd/web/handlers_test.go +++ b/cmd/web/handlers_test.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -113,10 +114,16 @@ func TestSnippetCreateGet(t *testing.T) { // simulate dependencies dummyLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) dummyDB := &models.SnippetModel{DB: nil} + templateCache, err := newTemplateCache() + + if err != nil { + t.Fatal(err) + } app := &Application{ - logger: dummyLogger, - snippets: dummyDB, + logger: dummyLogger, + snippets: dummyDB, + templateCache: templateCache, } router := app.routes() @@ -158,3 +165,33 @@ func TestSnippetCreatePost(t *testing.T) { t.Errorf("expected Location header %q; got %q", expectedLocation, actualLocation) } } + +func TestSnippetCreatePost_InvalidData(t *testing.T) { + templateCache, err := newTemplateCache() + + if err != nil { + t.Fatal(err) + } + app := &Application{ + templateCache: templateCache, + } + + form := url.Values{} + form.Add("title", "") + form.Add("content", "Some valid content") + form.Add("expires", "7") + + req := httptest.NewRequest(http.MethodPost, "/snippet/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rr := httptest.NewRecorder() + + app.SnippetCreatePost(rr, req) + + res := rr.Result() + defer res.Body.Close() + + if res.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("expected status 422 OK; got %d", res.StatusCode) + } +} diff --git a/cmd/web/helper.go b/cmd/web/helper.go index 287b241..307c040 100644 --- a/cmd/web/helper.go +++ b/cmd/web/helper.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "runtime/debug" + + "snippetbox.tonidefez.net/internal/models" ) // The serverError helper writes a log entry at Error level (including the request @@ -49,3 +51,10 @@ func (app *Application) render(w http.ResponseWriter, r *http.Request, status in w.WriteHeader(status) buf.WriteTo(w) } + +func (app *Application) NewTemplateData(r *http.Request) *templateData { + return &templateData{ + Snippet: models.Snippet{}, + Snippets: []models.Snippet{}, + } +} diff --git a/cmd/web/templates.go b/cmd/web/templates.go index 08336ba..7cc669c 100644 --- a/cmd/web/templates.go +++ b/cmd/web/templates.go @@ -13,8 +13,10 @@ import ( // At the moment it only contains one field, but we'll add more // to it as the build progresses. type templateData struct { - Snippet models.Snippet - Snippets []models.Snippet + CurrentYear int + Snippet models.Snippet + Snippets []models.Snippet + Form any } func newTemplateCache() (map[string]*template.Template, error) { diff --git a/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 0000000..696dfa7 --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,56 @@ +package validator + +import ( + "slices" + "strings" + "unicode/utf8" +) + +// Define a new Validator struct which contains a map of validation error messages +// for our form fields. +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) { + // Note: We need to initialize the map first, if it isn't already + // initialized. + 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) +} diff --git a/ui/html/pages/create.tmpl b/ui/html/pages/create.tmpl new file mode 100644 index 0000000..9ec668f --- /dev/null +++ b/ui/html/pages/create.tmpl @@ -0,0 +1,43 @@ +{{define "title"}}Create a New Snippet{{end}} + +{{define "main"}} +
+{{end}} \ No newline at end of file diff --git a/ui/html/partials/nav.tmpl b/ui/html/partials/nav.tmpl index e5222bf..571e8d3 100644 --- a/ui/html/partials/nav.tmpl +++ b/ui/html/partials/nav.tmpl @@ -1,5 +1,7 @@ {{define "nav"}} {{end}} \ No newline at end of file