diff --git a/README.md b/README.md index f3dcb3f..d6ad50f 100644 --- a/README.md +++ b/README.md @@ -177,4 +177,8 @@ The [official template docs](https://pkg.go.dev/text/template#hdr-Functions) can ### Headers -- [Primer on Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) \ No newline at end of file +- [Primer on Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) + +## Form Validation + +- https://www.alexedwards.net/blog/validation-snippets-for-go: Covers snippets for common form validation logic. \ No newline at end of file diff --git a/internal/model/snippets.go b/internal/model/snippets.go index ea4ac1e..07df2d2 100644 --- a/internal/model/snippets.go +++ b/internal/model/snippets.go @@ -19,6 +19,10 @@ type Snippet struct { ExpiresAt time.Time } +func (s *Snippet) GetTitle() { + return +} + type SnippetService struct { DB *sql.DB } diff --git a/internal/server/form.go b/internal/server/form.go new file mode 100644 index 0000000..e8105ee --- /dev/null +++ b/internal/server/form.go @@ -0,0 +1,13 @@ +package server + +// 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 +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 2d4f90f..c338ae3 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -6,6 +6,8 @@ import ( "log/slog" "net/http" "strconv" + "strings" + "unicode/utf8" "git.runcible.io/learning/ratchet/internal/model" ) @@ -120,9 +122,17 @@ func handleSnippetView(logger *slog.Logger, tc *TemplateCache, snippetService *m }) } -func handleSnippetCreateGet() http.Handler { +func handleSnippetCreateGet(tc *TemplateCache) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Create snippet form..")) + data := newTemplateData() + // Initialize a new snippetCreateForm instance and pass it to the template. + // Notice how this is also a great opportunity to set any default or + // 'initial' values for the form --- here we set the initial value for the + // snippet expiry to 365 days. + data.Form = snippetCreateForm{ + Expires: 365, + } + renderTemplate(w, r, tc, http.StatusOK, "create.go.tmpl", data) }) } @@ -135,13 +145,91 @@ func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, snippetServ // example of a custom header. Must be done before calling WriteHeader // or they will fail to take effect. w.Header().Add("Server", "Dirp") - // Create some variables holding dummy data. We'll remove these later on - // during the build. - title := "O snail" - content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa" - expires := 7 - id, err := snippetService.Insert(title, content, expires) + // Limit the request body size to 4096 bytes + // by default it's 10mb unless enctype="multipart/form-data in which case + // thier is no limit. You would do this however to limit the number of bytes + // that r.ParseForm will process. In this case it is only the first 4096 bytes + // reading beyond that will cause the MaxBytesReader to throw and error surfaced + // in your call to r.ParseForm() + // Also if the limit is reached the reader flags the server which will terminate + // the under lying TCP connection. + r.Body = http.MaxBytesReader(w, r.Body, 4096) + + // ParseForm is idempotent. Can be called many times. + // 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. + + // If you have a form that submits data using the HTTP method GET, rather than POST, + // the form data will be included as URL query string parameters /foo/bar?title=value&content=value. + // can be retrieved using r.URL.Query().Get() which will return the value or an empty string. + // r.Form could be used, but it is clearer and more explicit to stick with r.URL.Query and r.PostForm + // to access submitted data. + err := r.ParseForm() + if err != nil { + clientError(w, http.StatusBadRequest) + return + } + + // 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 { + 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, + FieldErrors: map[string]string{}, + } + + // 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" + } + + // 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 := newTemplateData() + data.Form = form + renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "create.go.tmpl", data) + return + } + + // NOTE + // + // In the case of a form field with multiple values you’ll need to work with the + // r.PostForm map directly. The underlying type of the r.PostForm map is url.Values, + // which in turn has the underlying type map[string][]string. So, for fields with + // multiple values you can loop over the underlying map to access them like so: + // for i, item := range r.PostForm["items"] { + // fmt.Fprintf(w, "%d: Item %s\n", i, item) + // } + + id, err := snippetService.Insert(form.Title, form.Content, form.Expires) if err != nil { serverError(w, r, err) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 5563ebe..7d3cb1f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -20,7 +20,7 @@ func addRoutes(mux *http.ServeMux, // GET will match GET & HEAD http request methods mux.Handle("GET /{$}", handleHome(logger, tc, snippetService)) mux.Handle("GET /snippet/view/{id}", handleSnippetView(logger, tc, snippetService)) - mux.Handle("GET /snippet/create", handleSnippetCreateGet()) + mux.Handle("GET /snippet/create", handleSnippetCreateGet(tc)) mux.Handle("POST /snippet/create", handleSnippetCreatePost(logger, tc, snippetService)) // mux.Handle("/something", handleSomething(logger, config)) // mux.Handle("/healthz", handleHealthzPlease(logger)) diff --git a/internal/server/templates.go b/internal/server/templates.go index 2909eaa..e3c41fc 100644 --- a/internal/server/templates.go +++ b/internal/server/templates.go @@ -20,6 +20,7 @@ type templateData struct { CurrentYear int Snippet model.Snippet Snippets []model.Snippet + Form any } // newTemplateData is useful to inject default values. Example CSRF tokens for forms. diff --git a/ui/html/pages/create.go.tmpl b/ui/html/pages/create.go.tmpl new file mode 100644 index 0000000..e135aa4 --- /dev/null +++ b/ui/html/pages/create.go.tmpl @@ -0,0 +1,37 @@ +{{define "title"}}Create a New Snippet{{end}} + +{{define "main"}} +
+{{end}} \ No newline at end of file diff --git a/ui/html/partials/nav.go.tmpl b/ui/html/partials/nav.go.tmpl index 256e49a..5f9b5ab 100644 --- a/ui/html/partials/nav.go.tmpl +++ b/ui/html/partials/nav.go.tmpl @@ -1,5 +1,6 @@ {{define "nav" -}} {{- end}} \ No newline at end of file