From 8f3ee6aa8e9bcd6e667ddcea7ddb3a8dbe0d2d73 Mon Sep 17 00:00:00 2001 From: Toni Defez Date: Wed, 16 Apr 2025 17:37:08 +0200 Subject: [PATCH 1/5] feat: implement snippet creation form and associated handlers --- cmd/web/handlers.go | 32 ++++++++++++++++++++++++++++---- cmd/web/handlers_test.go | 10 ++++++++-- cmd/web/helper.go | 9 +++++++++ ui/html/pages/create.tmpl | 23 +++++++++++++++++++++++ ui/html/partials/nav.tmpl | 2 ++ 5 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 ui/html/pages/create.tmpl diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index f139c6a..3549912 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -50,13 +50,37 @@ 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) + + 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 + // First we call r.ParseForm() which adds any data in POST request bodies + // to the r.PostForm map. This also works in the same way for PUT and PATCH + // requests. If there are any errors, we use our app.ClientError() helper to + // send a 400 Bad Request response to the user. + err := r.ParseForm() + if err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + // Use the r.PostForm.Get() method to retrieve the title and content + // from the r.PostForm map. + title := r.PostForm.Get("title") + content := r.PostForm.Get("content") + + // The r.PostForm.Get() method always returns the form data as a *string*. + // However, we're expecting our expires value to be a number, and want to + // represent it in our Go code as an integer. So we need to manually convert + // the form data to an integer using strconv.Atoi(), and we send a 400 Bad + // Request response if the conversion fails. + expires, err := strconv.Atoi(r.PostForm.Get("expires")) + if err != nil { + app.clientError(w, http.StatusBadRequest) + return + } id, err := app.snippets.Insert(title, content, expires) if err != nil { diff --git a/cmd/web/handlers_test.go b/cmd/web/handlers_test.go index a11e2fd..9e40f91 100644 --- a/cmd/web/handlers_test.go +++ b/cmd/web/handlers_test.go @@ -113,10 +113,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() 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/ui/html/pages/create.tmpl b/ui/html/pages/create.tmpl new file mode 100644 index 0000000..e55507c --- /dev/null +++ b/ui/html/pages/create.tmpl @@ -0,0 +1,23 @@ +{{define "title"}}Create a New Snippet{{end}} + +{{define "main"}} +
+
+ + +
+
+ + +
+
+ + One Year + One Week + One Day +
+
+ +
+
+{{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 From 3e74078f06c50689346fed1985cd6cc427bbb450 Mon Sep 17 00:00:00 2001 From: Toni Defez Date: Wed, 16 Apr 2025 17:43:21 +0200 Subject: [PATCH 2/5] feat: add validation for snippet creation form fields --- cmd/web/handlers.go | 29 +++++++++++++++++++++++------ cmd/web/handlers_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 3549912..a93e5e6 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "strconv" + "strings" + "unicode/utf8" "snippetbox.tonidefez.net/internal/models" ) @@ -70,18 +72,33 @@ func (app *Application) SnippetCreatePost(w http.ResponseWriter, r *http.Request // from the r.PostForm map. title := r.PostForm.Get("title") content := r.PostForm.Get("content") - - // The r.PostForm.Get() method always returns the form data as a *string*. - // However, we're expecting our expires value to be a number, and want to - // represent it in our Go code as an integer. So we need to manually convert - // the form data to an integer using strconv.Atoi(), and we send a 400 Bad - // Request response if the conversion fails. expires, err := strconv.Atoi(r.PostForm.Get("expires")) if err != nil { app.clientError(w, http.StatusBadRequest) return } + fieldErrors := make(map[string]string) + + if strings.TrimSpace(title) == "" { + fieldErrors["title"] = "This field cannot be blank" + } else if utf8.RuneCountInString(title) > 100 { + fieldErrors["title"] = "This field cannot be more than 100 characters long" + } + + if strings.TrimSpace(content) == "" { + fieldErrors["content"] = "This field cannot be blank" + } + + if expires != 1 && expires != 7 && expires != 365 { + fieldErrors["expires"] = "This field must equal 1, 7 or 365" + } + + if len(fieldErrors) > 0 { + fmt.Fprint(w, fieldErrors) + return + } + id, err := app.snippets.Insert(title, content, expires) if err != nil { app.serverError(w, r, err) diff --git a/cmd/web/handlers_test.go b/cmd/web/handlers_test.go index 9e40f91..cb1205d 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" @@ -164,3 +165,26 @@ func TestSnippetCreatePost(t *testing.T) { t.Errorf("expected Location header %q; got %q", expectedLocation, actualLocation) } } + +func TestSnippetCreatePost_InvalidData(t *testing.T) { + app := &Application{} // no necesitas DB para este test + + 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.StatusOK { + t.Errorf("expected status 200 OK; got %d", res.StatusCode) + } +} From 5fa99b0493e8457bdade6942656e5a1fd713ce50 Mon Sep 17 00:00:00 2001 From: Toni Defez Date: Wed, 16 Apr 2025 18:03:33 +0200 Subject: [PATCH 3/5] feat: enhance snippet creation form with validation and error handling --- cmd/web/handlers.go | 66 +++++++++++++++++++++++++++------------ cmd/web/templates.go | 6 ++-- ui/html/pages/create.tmpl | 30 +++++++++++++++--- 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index a93e5e6..fc8bd2c 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -11,6 +11,18 @@ import ( "snippetbox.tonidefez.net/internal/models" ) +// 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 +} + func (app *Application) Home(w http.ResponseWriter, r *http.Request) { w.Header().Add("Server", "Go") @@ -54,52 +66,66 @@ func (app *Application) SnippetView(w http.ResponseWriter, r *http.Request) { func (app *Application) SnippetCreate(w http.ResponseWriter, r *http.Request) { 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) { - // First we call r.ParseForm() which adds any data in POST request bodies - // to the r.PostForm map. This also works in the same way for PUT and PATCH - // requests. If there are any errors, we use our app.ClientError() helper to - // send a 400 Bad Request response to the user. err := r.ParseForm() if err != nil { app.clientError(w, http.StatusBadRequest) return } - // Use the r.PostForm.Get() method to retrieve the title and content - // from the r.PostForm map. - title := r.PostForm.Get("title") - content := r.PostForm.Get("content") + // 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 } - fieldErrors := make(map[string]string) + // 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{}, + } - if strings.TrimSpace(title) == "" { - fieldErrors["title"] = "This field cannot be blank" - } else if utf8.RuneCountInString(title) > 100 { - fieldErrors["title"] = "This field cannot be more than 100 characters long" + // Update the validation checks so that they operate on the snippetCreateForm + // instance. + if strings.TrimSpace(form.Title) == "" { + form.FieldErrors["title"] = "This field cannot be blank" + } else if utf8.RuneCountInString(form.Title) > 100 { + form.FieldErrors["title"] = "This field cannot be more than 100 characters long" } - if strings.TrimSpace(content) == "" { - fieldErrors["content"] = "This field cannot be blank" + if strings.TrimSpace(form.Content) == "" { + form.FieldErrors["content"] = "This field cannot be blank" } - if expires != 1 && expires != 7 && expires != 365 { - fieldErrors["expires"] = "This field must equal 1, 7 or 365" + if form.Expires != 1 && form.Expires != 7 && form.Expires != 365 { + form.FieldErrors["expires"] = "This field must equal 1, 7 or 365" } - if len(fieldErrors) > 0 { - fmt.Fprint(w, fieldErrors) + // 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 { + 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) + // We also need to update this line to pass the data from the + // snippetCreateForm instance to our Insert() method. + 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/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/ui/html/pages/create.tmpl b/ui/html/pages/create.tmpl index e55507c..9ec668f 100644 --- a/ui/html/pages/create.tmpl +++ b/ui/html/pages/create.tmpl @@ -4,17 +4,37 @@
- + + {{with .Form.FieldErrors.title}} + + {{end}} + +
- + + {{with .Form.FieldErrors.content}} + + {{end}} + +
- One Year - One Week - One Day + + {{with .Form.FieldErrors.expires}} + + {{end}} + + One Year + + One Week + One Day
From 9f72b5244bbdb7c3e9ba56d8d5f38255b6e2f056 Mon Sep 17 00:00:00 2001 From: Toni Defez Date: Wed, 16 Apr 2025 18:19:45 +0200 Subject: [PATCH 4/5] feat: implement validation logic for snippet creation form --- cmd/web/handlers.go | 50 +++++++++++++---------------- internal/validator/validator.go | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 internal/validator/validator.go diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index fc8bd2c..3ed835d 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -5,10 +5,9 @@ import ( "fmt" "net/http" "strconv" - "strings" - "unicode/utf8" "snippetbox.tonidefez.net/internal/models" + "snippetbox.tonidefez.net/internal/validator" ) // Define a snippetCreateForm struct to represent the form data and validation @@ -21,6 +20,7 @@ type snippetCreateForm struct { Content string Expires int FieldErrors map[string]string + validator.Validator } func (app *Application) Home(w http.ResponseWriter, r *http.Request) { @@ -90,41 +90,33 @@ func (app *Application) SnippetCreatePost(w http.ResponseWriter, r *http.Request // 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, } - // Update the validation checks so that they operate on the snippetCreateForm - // instance. - if strings.TrimSpace(form.Title) == "" { - form.FieldErrors["title"] = "This field cannot be blank" - } else if utf8.RuneCountInString(form.Title) > 100 { - form.FieldErrors["title"] = "This field cannot be more than 100 characters long" - } - - if strings.TrimSpace(form.Content) == "" { - form.FieldErrors["content"] = "This field cannot be blank" - } - - if form.Expires != 1 && form.Expires != 7 && form.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 { + // 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 } - // We also need to update this line to pass the data from the - // snippetCreateForm instance to our Insert() method. id, err := app.snippets.Insert(form.Title, form.Content, form.Expires) if err != nil { app.serverError(w, r, err) 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) +} From df9cfb88ec8d27d207ab5d9cd6818a01c2bad070 Mon Sep 17 00:00:00 2001 From: Toni Defez Date: Wed, 16 Apr 2025 18:38:04 +0200 Subject: [PATCH 5/5] feat: update snippet creation post test to handle invalid data and check for unprocessable entity status --- cmd/web/handlers_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cmd/web/handlers_test.go b/cmd/web/handlers_test.go index cb1205d..878ea1c 100644 --- a/cmd/web/handlers_test.go +++ b/cmd/web/handlers_test.go @@ -167,7 +167,14 @@ func TestSnippetCreatePost(t *testing.T) { } func TestSnippetCreatePost_InvalidData(t *testing.T) { - app := &Application{} // no necesitas DB para este test + templateCache, err := newTemplateCache() + + if err != nil { + t.Fatal(err) + } + app := &Application{ + templateCache: templateCache, + } form := url.Values{} form.Add("title", "") @@ -184,7 +191,7 @@ func TestSnippetCreatePost_InvalidData(t *testing.T) { res := rr.Result() defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Errorf("expected status 200 OK; got %d", res.StatusCode) + if res.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("expected status 422 OK; got %d", res.StatusCode) } }