diff --git a/README.md b/README.md index 35a9785..3be4589 100644 --- a/README.md +++ b/README.md @@ -646,7 +646,7 @@ An example of this pattern is: type ContactForm struct { Email string `form:"email" validate:"required,email"` Message string `form:"message" validate:"required"` - Submission controller.FormSubmission + Submission form.Submission } ``` @@ -654,27 +654,23 @@ Then in your page: ```go page := controller.NewPage(ctx) -page.Form = ContactForm{} +page.Form = form.Get[ContactForm](ctx) ``` -How the _form_ gets populated with values so that your template can render them is covered in the next section. +This will either initialize a new form to be rendered, or load one previously stored in the context (ie, if it was already submitted). How the _form_ gets populated with values so that your template can render them is covered in the next section. #### Submission processing -Form submission processing is made extremely simple by leveraging functionality provided by [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator) and the `FormSubmission` struct located in `pkg/controller/form.go`. +Form submission processing is made extremely simple by leveraging functionality provided by [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator) and the `Submission` struct located in `pkg/form/form.go`. Using the example form above, these are the steps you would take within the _POST_ callback for your route: -Start by storing a pointer to the form in the context so that your _GET_ callback can access the form values, which will be showed at the end: +Start by setting the form in the request contxt. This stores a pointer to the form so that your _GET_ callback can access the form values (shown previously). It also will parse the input in the POST data to map to the struct so it becomes populated. This uses the `form` struct tags to map form values to the struct fields. ```go -var form ContactForm -ctx.Set(context.FormKey, &form) -``` +var input ContactForm -Parse the input in the POST data to map to the struct so it becomes populated. This uses the `form` struct tags to map form values to the struct fields. -```go -if err := ctx.Bind(&form); err != nil { - // Something went wrong... +if err := form.Set(ctx, &input); err != nil { + return err } ``` @@ -695,17 +691,7 @@ if !form.Submission.HasErrors() { In the event of a validation error, you most likely want to re-render the form with the values provided and any error messages. Since you stored a pointer to the _form_ in the context in the first step, you can first have the _POST_ handler call the _GET_: ```go if form.Submission.HasErrors() { - return c.Get(ctx) -} -``` - -Then, in your _GET_ handler, extract the form from the context so it can be passed to the templates: -```go -page := controller.NewPage(ctx) -page.Form = ContactForm{} - -if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*ContactForm) + return c.GetCallback(ctx) } ``` @@ -716,9 +702,9 @@ And finally, your template: #### Inline validation -The `FormSubmission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that your templates can use to provide classes and extract the error messages. +The `Submission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that your templates can use to provide classes and extract the error messages. -While [validator](https://github.com/go-playground/validator) is a great package that is used to validate based on struct tags, the downside is that the messaging, by default, is not very human-readable or easy to override. Within `FormSubmission.setErrorMessages()` the validation errors are converted to more readable messages based on the tag that failed validation. Only a few tags are provided as an example, so be sure to expand on that as needed. +While [validator](https://github.com/go-playground/validator) is a great package that is used to validate based on struct tags, the downside is that the messaging, by default, is not very human-readable or easy to override. Within `Submission.setErrorMessages()` the validation errors are converted to more readable messages based on the tag that failed validation. Only a few tags are provided as an example, so be sure to expand on that as needed. To provide the inline validation in your template, there are two things that need to be done. diff --git a/pkg/form/form.go b/pkg/form/form.go new file mode 100644 index 0000000..3104ddf --- /dev/null +++ b/pkg/form/form.go @@ -0,0 +1,34 @@ +package form + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/context" +) + +// Get gets a form from the context or initializes a new copy if one is not set +func Get[T any](ctx echo.Context) *T { + if v := ctx.Get(context.FormKey); v != nil { + return v.(*T) + } + var v T + return &v +} + +// Set sets a form in the context and binds the request values to it +func Set(ctx echo.Context, form any) error { + ctx.Set(context.FormKey, form) + + if err := ctx.Bind(form); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("unable to bind form: %v", err)) + } + + return nil +} + +// Clear removes the form set in the context +func Clear(ctx echo.Context) { + ctx.Set(context.FormKey, nil) +} diff --git a/pkg/form/form_test.go b/pkg/form/form_test.go new file mode 100644 index 0000000..61a39e0 --- /dev/null +++ b/pkg/form/form_test.go @@ -0,0 +1,59 @@ +package form + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContextFuncs(t *testing.T) { + e := echo.New() + + type example struct { + Name string `form:"name"` + } + + t.Run("get empty context", func(t *testing.T) { + // Empty context, still return a form + ctx, _ := tests.NewContext(e, "/") + form := Get[example](ctx) + assert.NotNil(t, form) + }) + + t.Run("set bad request", func(t *testing.T) { + // Set with a bad request + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("abc=abc")) + ctx := e.NewContext(req, httptest.NewRecorder()) + var form example + err := Set(ctx, &form) + assert.Error(t, err) + }) + + t.Run("set", func(t *testing.T) { + // Set and parse the values + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("name=abc")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + ctx := e.NewContext(req, httptest.NewRecorder()) + var form example + err := Set(ctx, &form) + require.NoError(t, err) + assert.Equal(t, "abc", form.Name) + + // Get again and expect the values were stored + got := Get[example](ctx) + require.NotNil(t, got) + assert.Equal(t, "abc", form.Name) + + // Clear + Clear(ctx) + got = Get[example](ctx) + require.NotNil(t, got) + assert.Empty(t, got.Name) + }) +} diff --git a/pkg/controller/form.go b/pkg/form/submission.go similarity index 77% rename from pkg/controller/form.go rename to pkg/form/submission.go index 9a357a6..777b6f5 100644 --- a/pkg/controller/form.go +++ b/pkg/form/submission.go @@ -1,4 +1,4 @@ -package controller +package form import ( "github.com/go-playground/validator/v10" @@ -6,8 +6,8 @@ import ( "github.com/labstack/echo/v4" ) -// FormSubmission represents the state of the submission of a form, not including the form itself -type FormSubmission struct { +// Submission represents the state of the submission of a form, not including the form itself +type Submission struct { // IsSubmitted indicates if the form has been submitted IsSubmitted bool @@ -16,7 +16,7 @@ type FormSubmission struct { } // Process processes a submission for a form -func (f *FormSubmission) Process(ctx echo.Context, form any) error { +func (f *Submission) Process(ctx echo.Context, form any) error { f.Errors = make(map[string][]string) f.IsSubmitted = true @@ -29,7 +29,7 @@ func (f *FormSubmission) Process(ctx echo.Context, form any) error { } // HasErrors indicates if the submission has any validation errors -func (f FormSubmission) HasErrors() bool { +func (f Submission) HasErrors() bool { if f.Errors == nil { return false } @@ -37,12 +37,12 @@ func (f FormSubmission) HasErrors() bool { } // FieldHasErrors indicates if a given field on the form has any validation errors -func (f FormSubmission) FieldHasErrors(fieldName string) bool { +func (f Submission) FieldHasErrors(fieldName string) bool { return len(f.GetFieldErrors(fieldName)) > 0 } // SetFieldError sets an error message for a given field name -func (f *FormSubmission) SetFieldError(fieldName string, message string) { +func (f *Submission) SetFieldError(fieldName string, message string) { if f.Errors == nil { f.Errors = make(map[string][]string) } @@ -50,7 +50,7 @@ func (f *FormSubmission) SetFieldError(fieldName string, message string) { } // GetFieldErrors gets the errors for a given field name -func (f FormSubmission) GetFieldErrors(fieldName string) []string { +func (f Submission) GetFieldErrors(fieldName string) []string { if f.Errors == nil { return []string{} } @@ -58,7 +58,7 @@ func (f FormSubmission) GetFieldErrors(fieldName string) []string { } // GetFieldStatusClass returns an HTML class based on the status of the field -func (f FormSubmission) GetFieldStatusClass(fieldName string) string { +func (f Submission) GetFieldStatusClass(fieldName string) string { if f.IsSubmitted { if f.FieldHasErrors(fieldName) { return "is-danger" @@ -70,12 +70,12 @@ func (f FormSubmission) GetFieldStatusClass(fieldName string) string { // IsDone indicates if the submission is considered done which is when it has been submitted // and there are no errors. -func (f FormSubmission) IsDone() bool { +func (f Submission) IsDone() bool { return f.IsSubmitted && !f.HasErrors() } // setErrorMessages sets errors messages on the submission for all fields that failed validation -func (f *FormSubmission) setErrorMessages(err error) { +func (f *Submission) setErrorMessages(err error) { // Only this is supported right now ves, ok := err.(validator.ValidationErrors) if !ok { diff --git a/pkg/controller/form_test.go b/pkg/form/submission_test.go similarity index 80% rename from pkg/controller/form_test.go rename to pkg/form/submission_test.go index e2fd880..fe10d6b 100644 --- a/pkg/controller/form_test.go +++ b/pkg/form/submission_test.go @@ -1,8 +1,10 @@ -package controller +package form import ( "testing" + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/tests" "github.com/stretchr/testify/assert" @@ -13,16 +15,18 @@ func TestFormSubmission(t *testing.T) { type formTest struct { Name string `validate:"required"` Email string `validate:"required,email"` - Submission FormSubmission + Submission Submission } - ctx, _ := tests.NewContext(c.Web, "/") + e := echo.New() + e.Validator = services.NewValidator() + ctx, _ := tests.NewContext(e, "/") form := formTest{ Name: "", Email: "a@a.com", } err := form.Submission.Process(ctx, form) - assert.NoError(t, err) + require.NoError(t, err) assert.True(t, form.Submission.HasErrors()) assert.True(t, form.Submission.FieldHasErrors("Name")) diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 6be0819..567b663 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -9,6 +9,7 @@ import ( "github.com/mikestefanello/pagoda/ent/user" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/services" @@ -38,13 +39,13 @@ type ( forgotPasswordForm struct { Email string `form:"email" validate:"required,email"` - Submission controller.FormSubmission + Submission form.Submission } loginForm struct { Email string `form:"email" validate:"required,email"` Password string `form:"password" validate:"required"` - Submission controller.FormSubmission + Submission form.Submission } registerForm struct { @@ -52,13 +53,13 @@ type ( Email string `form:"email" validate:"required,email"` Password string `form:"password" validate:"required"` ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` - Submission controller.FormSubmission + Submission form.Submission } resetPasswordForm struct { Password string `form:"password" validate:"required"` ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` - Submission controller.FormSubmission + Submission form.Submission } ) @@ -99,42 +100,37 @@ func (c *Auth) ForgotPasswordPage(ctx echo.Context) error { page.Layout = templates.LayoutAuth page.Name = templates.PageForgotPassword page.Title = "Forgot password" - page.Form = forgotPasswordForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*forgotPasswordForm) - } + page.Form = form.Get[forgotPasswordForm](ctx) return c.RenderPage(ctx, page) } func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error { - var form forgotPasswordForm - ctx.Set(context.FormKey, &form) + var input forgotPasswordForm succeed := func() error { - ctx.Set(context.FormKey, nil) + form.Clear(ctx) msg.Success(ctx, "An email containing a link to reset your password will be sent to this address if it exists in our system.") return c.ForgotPasswordPage(ctx) } - // Parse the form values - if err := ctx.Bind(&form); err != nil { - return c.Fail(err, "unable to parse forgot password form") + // Set the form in context and parse the form values + if err := form.Set(ctx, &input); err != nil { + return err } - if err := form.Submission.Process(ctx, form); err != nil { + if err := input.Submission.Process(ctx, input); err != nil { return c.Fail(err, "unable to process form submission") } - if form.Submission.HasErrors() { + if input.Submission.HasErrors() { return c.ForgotPasswordPage(ctx) } // Attempt to load the user u, err := c.orm.User. Query(). - Where(user.Email(strings.ToLower(form.Email))). + Where(user.Email(strings.ToLower(input.Email))). Only(ctx.Request().Context()) switch err.(type) { @@ -174,43 +170,38 @@ func (c *Auth) LoginPage(ctx echo.Context) error { page.Layout = templates.LayoutAuth page.Name = templates.PageLogin page.Title = "Log in" - page.Form = loginForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*loginForm) - } + page.Form = form.Get[loginForm](ctx) return c.RenderPage(ctx, page) } func (c *Auth) LoginSubmit(ctx echo.Context) error { - var form loginForm - ctx.Set(context.FormKey, &form) + var input loginForm authFailed := func() error { - form.Submission.SetFieldError("Email", "") - form.Submission.SetFieldError("Password", "") + input.Submission.SetFieldError("Email", "") + input.Submission.SetFieldError("Password", "") msg.Danger(ctx, "Invalid credentials. Please try again.") return c.LoginPage(ctx) } - // Parse the form values - if err := ctx.Bind(&form); err != nil { - return c.Fail(err, "unable to parse login form") + // Set in context and parse the form values + if err := form.Set(ctx, &input); err != nil { + return err } - if err := form.Submission.Process(ctx, form); err != nil { + if err := input.Submission.Process(ctx, input); err != nil { return c.Fail(err, "unable to process form submission") } - if form.Submission.HasErrors() { + if input.Submission.HasErrors() { return c.LoginPage(ctx) } // Attempt to load the user u, err := c.orm.User. Query(). - Where(user.Email(strings.ToLower(form.Email))). + Where(user.Email(strings.ToLower(input.Email))). Only(ctx.Request().Context()) switch err.(type) { @@ -222,7 +213,7 @@ func (c *Auth) LoginSubmit(ctx echo.Context) error { } // Check if the password is correct - err = c.auth.CheckPassword(form.Password, u.Password) + err = c.auth.CheckPassword(input.Password, u.Password) if err != nil { return authFailed() } @@ -251,34 +242,29 @@ func (c *Auth) RegisterPage(ctx echo.Context) error { page.Layout = templates.LayoutAuth page.Name = templates.PageRegister page.Title = "Register" - page.Form = registerForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*registerForm) - } + page.Form = form.Get[registerForm](ctx) return c.RenderPage(ctx, page) } func (c *Auth) RegisterSubmit(ctx echo.Context) error { - var form registerForm - ctx.Set(context.FormKey, &form) + var input registerForm - // Parse the form values - if err := ctx.Bind(&form); err != nil { + // Set in context and parse the form values + if err := form.Set(ctx, &input); err != nil { return c.Fail(err, "unable to parse register form") } - if err := form.Submission.Process(ctx, form); err != nil { + if err := input.Submission.Process(ctx, input); err != nil { return c.Fail(err, "unable to process form submission") } - if form.Submission.HasErrors() { + if input.Submission.HasErrors() { return c.RegisterPage(ctx) } // Hash the password - pwHash, err := c.auth.HashPassword(form.Password) + pwHash, err := c.auth.HashPassword(input.Password) if err != nil { return c.Fail(err, "unable to hash password") } @@ -286,8 +272,8 @@ func (c *Auth) RegisterSubmit(ctx echo.Context) error { // Attempt creating the user u, err := c.orm.User. Create(). - SetName(form.Name). - SetEmail(form.Email). + SetName(input.Name). + SetEmail(input.Email). SetPassword(pwHash). Save(ctx.Request().Context()) @@ -347,34 +333,29 @@ func (c *Auth) ResetPasswordPage(ctx echo.Context) error { page.Layout = templates.LayoutAuth page.Name = templates.PageResetPassword page.Title = "Reset password" - page.Form = resetPasswordForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*resetPasswordForm) - } + page.Form = form.Get[resetPasswordForm](ctx) return c.RenderPage(ctx, page) } func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error { - var form resetPasswordForm - ctx.Set(context.FormKey, &form) + var input resetPasswordForm - // Parse the form values - if err := ctx.Bind(&form); err != nil { + // Set in context and parse the form values + if err := form.Set(ctx, &input); err != nil { return c.Fail(err, "unable to parse password reset form") } - if err := form.Submission.Process(ctx, form); err != nil { + if err := input.Submission.Process(ctx, input); err != nil { return c.Fail(err, "unable to process form submission") } - if form.Submission.HasErrors() { + if input.Submission.HasErrors() { return c.ResetPasswordPage(ctx) } // Hash the new password - hash, err := c.auth.HashPassword(form.Password) + hash, err := c.auth.HashPassword(input.Password) if err != nil { return c.Fail(err, "unable to hash password") } diff --git a/pkg/handlers/contact.go b/pkg/handlers/contact.go index 6fa3f34..af4a45e 100644 --- a/pkg/handlers/contact.go +++ b/pkg/handlers/contact.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" ) @@ -25,7 +25,7 @@ type ( Email string `form:"email" validate:"required,email"` Department string `form:"department" validate:"required,oneof=sales marketing hr"` Message string `form:"message" validate:"required"` - Submission controller.FormSubmission + Submission form.Submission } ) @@ -49,34 +49,29 @@ func (c *Contact) Page(ctx echo.Context) error { page.Layout = templates.LayoutMain page.Name = templates.PageContact page.Title = "Contact us" - page.Form = contactForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*contactForm) - } + page.Form = form.Get[contactForm](ctx) return c.RenderPage(ctx, page) } func (c *Contact) Submit(ctx echo.Context) error { - var form contactForm - ctx.Set(context.FormKey, &form) + var input contactForm - // Parse the form values - if err := ctx.Bind(&form); err != nil { - return c.Fail(err, "unable to bind form") + // Store in context and parse the form values + if err := form.Set(ctx, &input); err != nil { + return err } - if err := form.Submission.Process(ctx, form); err != nil { + if err := input.Submission.Process(ctx, input); err != nil { return c.Fail(err, "unable to process form submission") } - if !form.Submission.HasErrors() { + if !input.Submission.HasErrors() { err := c.mail. Compose(). - To(form.Email). + To(input.Email). Subject("Contact form submitted"). - Body(fmt.Sprintf("The message is: %s", form.Message)). + Body(fmt.Sprintf("The message is: %s", input.Message)). Send(ctx) if err != nil {