diff --git a/model/email.go b/model/email.go new file mode 100644 index 0000000..9e78d47 --- /dev/null +++ b/model/email.go @@ -0,0 +1,35 @@ +package model + +import ( + "regexp" + "strings" +) + +// emailAddressMatcher for valid email addresses. +// See https://regex101.com/r/1BEPJo/latest for an interactive breakdown of the regexp. +// See https://html.spec.whatwg.org/#valid-e-mail-address for the definition. +var emailAddressMatcher = regexp.MustCompile( + // Start of string + `^` + + // Local part of the address. Note that \x60 is a backtick (`) character. + `(?P[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+)` + + `@` + + // Domain of the address + `(?P[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)` + + // End of string + `$`, +) + +type Email string + +func (e Email) IsValid() bool { + return emailAddressMatcher.MatchString(string(e)) +} + +func (e Email) String() string { + return string(e) +} + +func (e Email) ToLower() Email { + return Email(strings.TrimSpace(strings.ToLower(string(e)))) +} diff --git a/model/email_test.go b/model/email_test.go new file mode 100644 index 0000000..c45a2f0 --- /dev/null +++ b/model/email_test.go @@ -0,0 +1,31 @@ +package model_test + +import ( + "testing" + + "maragu.dev/is" + + "maragu.dev/goo/model" +) + +func TestEmail_IsValid(t *testing.T) { + tests := []struct { + address string + valid bool + }{ + {"me@example.com", true}, + {"@example.com", false}, + {"me@", false}, + {"@", false}, + {"", false}, + {"me@example", false}, + } + t.Run("reports valid email addresses", func(t *testing.T) { + for _, test := range tests { + t.Run(test.address, func(t *testing.T) { + e := model.Email(test.address) + is.Equal(t, test.valid, e.IsValid()) + }) + } + }) +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 0000000..183f1a3 --- /dev/null +++ b/model/model.go @@ -0,0 +1,7 @@ +package model + +type ID string + +func (i ID) String() string { + return string(i) +} diff --git a/model/time.go b/model/time.go new file mode 100644 index 0000000..2ec775f --- /dev/null +++ b/model/time.go @@ -0,0 +1,60 @@ +package model + +import ( + "database/sql/driver" + "fmt" + "time" +) + +// rfc3339Milli is like time.RFC3339Nano, but with millisecond precision, and fractional seconds do not have trailing +// zeros removed. +const rfc3339Milli = "2006-01-02T15:04:05.000Z07:00" + +type Time struct { + T time.Time +} + +func (t *Time) String() string { + if t == nil { + return "" + } + return t.T.UTC().Format(rfc3339Milli) +} + +func ParseTime(v string) (Time, error) { + t, err := time.Parse(rfc3339Milli, v) + if err != nil { + return Time{}, err + } + return Time{T: t}, nil +} + +// Value satisfies driver.Valuer interface. +func (t Time) Value() (driver.Value, error) { + return t.T.UTC().Format(rfc3339Milli), nil +} + +// Scan satisfies sql.Scanner interface. +func (t *Time) Scan(src any) error { + if src == nil { + return nil + } + + s, ok := src.(string) + if !ok { + return fmt.Errorf("error scanning time, got %+v", src) + } + + parsedT, err := time.Parse(rfc3339Milli, s) + if err != nil { + return err + } + + t.T = parsedT.UTC() + + return nil +} + +func Now() *Time { + return &Time{T: time.Now()} +}