Skip to content

Commit

Permalink
clean up htmxform request
Browse files Browse the repository at this point in the history
  • Loading branch information
dkotik committed Feb 29, 2024
1 parent bc44684 commit e361747
Show file tree
Hide file tree
Showing 13 changed files with 318 additions and 300 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,5 @@ The core idea was sparked in conversations with members of the Ardan Labs team.

- Generic REST Controllers: <https://github.com/dolanor/rip/>
- Baby API: <https://github.com/calvinmclean/babyapi>

<!-- BabyAPI is doesn't really gel naturally with standard library by requiring their own primitives - this just returns http.Handler. Dolanor's REST controllers are similar, but he tries to implement the entire REST interface, which is way more magic. This doesn't care about REST - that is the mux's problem, htadaptor just wraps Handlers. -->
3 changes: 3 additions & 0 deletions error.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ type DecodingError struct {
func NewDecodingError(fromError error) Error {
underlying, ok := fromError.(Error)
if ok {
// the underlying error has a more precise HTTP
// status code than http.StatusUnprocessableEntity
// which will be assigned by [DecodingError]
return underlying
}
return &DecodingError{fromError}
Expand Down
20 changes: 0 additions & 20 deletions examples/htmxform/email.go

This file was deleted.

155 changes: 84 additions & 71 deletions examples/htmxform/feedback/feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ package feedback
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"strings"

Expand All @@ -17,95 +15,110 @@ import (

var reValidEmail = regexp.MustCompile(`^[^\@]+\@[^\@]+\.\w+$`)

type Sender func(context.Context, *Request) error
type Sender func(context.Context, *Letter) error

func New(send Sender, withOptions ...htadaptor.Option) (http.Handler, error) {
if send == nil {
return nil, errors.New("cannot use a <nil> feedback sender")
}
return htadaptor.NewUnaryFuncAdaptor(
func(ctx context.Context, r *Request) (p *Response, err error) {
l, ok := htadaptor.LocalizerFromContext(ctx)
if !ok {
return nil, errors.New("there is no localizer in context")
}

if err = send(ctx, r); err != nil {
return nil, fmt.Errorf(l.MustLocalize(&i18n.LocalizeConfig{
MessageID: MsgError,
TemplateData: map[string]any{
"Error": "%w",
},
}), err)
}
return &Response{
Message: l.MustLocalize(
&i18n.LocalizeConfig{
MessageID: MsgSent,
},
),
}, nil
},
withOptions...,
)
}

type Request struct {
type Letter struct {
Name string
Phone string
Email string
Message string
}

func (r *Request) Validate(ctx context.Context) error {
l, ok := htadaptor.LocalizerFromContext(ctx)
func (l *Letter) Validate(ctx context.Context) error {
locale, ok := htadaptor.LocalizerFromContext(ctx)
if !ok {
return errors.New("there is no localizer in context")
}
// separating localized validation, because HTMX handler may
// call it directly
return r.ValidateWithLocale(l)
return l.ValidateWithLocale(locale)
}

func (r *Request) ValidateWithLocale(l *i18n.Localizer) error {
if len(r.Name) < 4 {
return errors.New(l.MustLocalize(&i18n.LocalizeConfig{
MessageID: MsgRequired,
TemplateData: map[string]any{
"Field": strings.ToLower(l.MustLocalize(&i18n.LocalizeConfig{
MessageID: MsgName,
})),
},
}))
func newRequiredError(field string, l *i18n.Localizer) error {
msg, err := l.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "Required",
Other: "Please provide {{.Field}}.",
},
TemplateData: map[string]any{
"Field": strings.ToLower(field),
},
})
if err != nil {
return err
}
if len(r.Email) < 4 {
return errors.New(l.MustLocalize(&i18n.LocalizeConfig{
MessageID: MsgRequired,
TemplateData: map[string]any{
"Field": strings.ToLower(l.MustLocalize(&i18n.LocalizeConfig{
MessageID: MsgEmail,
})),
},
}))
return errors.New(msg)
}

func (l *Letter) ValidateWithLocale(locale *i18n.Localizer) error {
if len(l.Name) < 4 {
field, err := l.nameLabel(locale)
if err != nil {
return err
}
return newRequiredError(field, locale)
}
if !reValidEmail.MatchString(r.Email) {
return errors.New(l.MustLocalize(&i18n.LocalizeConfig{
MessageID: MsgEmailError,
}))
if len(l.Email) < 4 {
field, err := l.emailLabel(locale)
if err != nil {
return err
}
return newRequiredError(field, locale)
}
if len(r.Message) < 4 {
return errors.New(l.MustLocalize(&i18n.LocalizeConfig{
MessageID: MsgRequired,
TemplateData: map[string]any{
"Field": strings.ToLower(l.MustLocalize(&i18n.LocalizeConfig{
MessageID: MsgMessage,
})),
if !reValidEmail.MatchString(l.Email) {
errorMessage, err := locale.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "EmailFormatError",
Other: "Invalid electronic mail address.",
},
}))
})
if err != nil {
return err
}
return errors.New(errorMessage)
}
if len(l.Message) < 4 {
field, err := l.messageLabel(locale)
if err != nil {
return err
}
return newRequiredError(field, locale)
}
return nil
}

type Response struct {
Message string `json:"message"` // to lower case
func (l *Letter) nameLabel(locale *i18n.Localizer) (string, error) {
return locale.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "Name",
Other: "Your Name",
},
})
}

func (l *Letter) phoneLabel(locale *i18n.Localizer) (string, error) {
return locale.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "Phone",
Other: "Phone Number",
},
})
}

func (l *Letter) emailLabel(locale *i18n.Localizer) (string, error) {
return locale.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "Email",
Other: "Email Address",
},
})
}

func (l *Letter) messageLabel(locale *i18n.Localizer) (string, error) {
return locale.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "Message",
Other: "Message",
},
})
}
154 changes: 154 additions & 0 deletions examples/htmxform/feedback/form.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package feedback

import (
"context"
_ "embed" // for templates
"errors"
"fmt"
"html/template"
"net/http"

"github.com/dkotik/htadaptor"
"github.com/nicksnyder/go-i18n/v2/i18n"
)

//go:embed form.htmx
var htmx string

var templates = template.Must(template.New("htmx").Parse(htmx))

type formRequest struct {
Letter // embed letter to defer validation handler function
}

func (r *formRequest) Validate(ctx context.Context) error {
// do nothing, because Letter validation
// will be performed inside the handler function
// because HTMX will render the error together with
// the rest of the response
return nil
}

type formResponse struct {
Letter // embed request to inject form values into form
Sent bool
Localizer *i18n.Localizer
Error error
}

func (f *formResponse) Title() (string, error) {
return f.Localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "PageTitle",
Other: "Provide Feedback",
},
})
}

func (f *formResponse) NameLabel() (string, error) {
return f.Letter.nameLabel(f.Localizer)
}

func (f *formResponse) PhoneLabel() (string, error) {

return f.Letter.phoneLabel(f.Localizer)
}

func (f *formResponse) EmailLabel() (string, error) {

return f.Letter.emailLabel(f.Localizer)
}

func (f *formResponse) MessageLabel() (string, error) {
return f.Letter.messageLabel(f.Localizer)
}

func (f *formResponse) SendLabel() (string, error) {
return f.Localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "Send",
Other: "Send",
},
})
}

func (f *formResponse) Success() (string, error) {
return f.Localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "Sent",
Other: "Thank you! We will follow up with you soon.",
},
})
}

type formHandler struct {
get http.Handler
post http.Handler
}

func (h *formHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.get.ServeHTTP(w, r)
case http.MethodPost:
h.post.ServeHTTP(w, r)
default:
h.get.ServeHTTP(w, r)
}
}

func New(sender Sender) (http.Handler, error) {
if sender == nil {
return nil, errors.New("cannot use a <nil> feedback sender")
}

get, err := htadaptor.NewNullaryFuncAdaptor(
func(ctx context.Context) (*formResponse, error) {
// localizer is passed through context using
// acceptlanguage middleware all the same
l, ok := htadaptor.LocalizerFromContext(ctx)
if !ok {
return nil, errors.New("there is no localizer in context")
}
return &formResponse{
Localizer: l,
}, nil
},
htadaptor.WithTemplate(templates.Lookup("page")),
)
if err != nil {
return nil, fmt.Errorf("unable to create get handler: %w", err)
}

post, err := htadaptor.NewUnaryFuncAdaptor(
func(ctx context.Context, r *formRequest) (*formResponse, error) {
// localizer is passed through context using
// acceptlanguage middleware
l, ok := htadaptor.LocalizerFromContext(ctx)
if !ok {
return nil, errors.New("there is no localizer in context")
}
f := &formResponse{
Letter: r.Letter,
Localizer: l,
}
if f.Error = r.Letter.ValidateWithLocale(l); f.Error != nil {
return f, nil
}
f.Error = sender(ctx, &r.Letter)
if f.Error == nil {
f.Sent = true
}
return f, nil
},
htadaptor.WithTemplate(templates.Lookup("form")),
)
if err != nil {
return nil, fmt.Errorf("unable to create post handler: %w", err)
}

return &formHandler{
get: get,
post: post,
}, nil
}
Loading

0 comments on commit e361747

Please sign in to comment.