diff --git a/README.md b/README.md index 3d7014d..c0f37f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # webutil -A collection of utilities for making web apps. \ No newline at end of file +## Roadmap + +* logging middleware diff --git a/formscanner.go b/formscanner.go new file mode 100644 index 0000000..8cd17ac --- /dev/null +++ b/formscanner.go @@ -0,0 +1,43 @@ +package webutil + +import ( + "errors" + "fmt" + "net/url" +) + +var ErrUnsupportedType = errors.New("unsupported type") + +type FormScanner struct { + form url.Values + err error +} + +func NewFormScanner(form url.Values) *FormScanner { + return &FormScanner{form: form} +} + +func (s *FormScanner) Scan(name string, val any) *FormScanner { + if s.err != nil { + return s + } + + switch v := val.(type) { + case *bool: + *v = s.form.Has(name) + default: + if err := scan(s.form.Get(name), v); err != nil { + s.err = fmt.Errorf("Error in field %s: %w", name, err) + } + } + + return s +} + +func (s *FormScanner) Error() error { + return s.err +} + +func (s *FormScanner) SetError(err error) { + s.err = err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7f5738d --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.crumpington.com/lib/webutil + +go 1.25.1 + +require golang.org/x/crypto v0.53.0 + +require ( + golang.org/x/net v0.55.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ce7c111 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/listenandserve.go b/listenandserve.go new file mode 100644 index 0000000..ebfbee1 --- /dev/null +++ b/listenandserve.go @@ -0,0 +1,24 @@ +package webutil + +import ( + "errors" + "net/http" + "strings" + + "golang.org/x/crypto/acme/autocert" +) + +// Serve requests using the given http.Server. If srv.Addr has the format +// `hostname:https`, then use autocert to manage certificates for the domain. +// +// For http on port 80, you can use :http. +func ListenAndServe(srv *http.Server) error { + if strings.HasSuffix(srv.Addr, ":https") { + hostname := strings.TrimSuffix(srv.Addr, ":https") + if len(hostname) == 0 { + return errors.New("https requires a hostname") + } + return srv.Serve(autocert.NewListener(hostname)) + } + return srv.ListenAndServe() +} diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..286612e --- /dev/null +++ b/scanner.go @@ -0,0 +1,85 @@ +package webutil + +import "strconv" + +func scan(raw string, val any) error { + switch v := val.(type) { + case *string: + *v = raw + case *int: + if i, err := strconv.ParseInt(raw, 10, 64); err != nil { + return err + } else { + *v = int(i) + } + case *int8: + if i, err := strconv.ParseInt(raw, 10, 8); err != nil { + return err + } else { + *v = int8(i) + } + case *int16: + if i, err := strconv.ParseInt(raw, 10, 16); err != nil { + return err + } else { + *v = int16(i) + } + case *int32: + if i, err := strconv.ParseInt(raw, 10, 32); err != nil { + return err + } else { + *v = int32(i) + } + case *int64: + if i, err := strconv.ParseInt(raw, 10, 64); err != nil { + return err + } else { + *v = int64(i) + } + case *uint: + if i, err := strconv.ParseUint(raw, 10, 64); err != nil { + return err + } else { + *v = uint(i) + } + case *uint8: + if i, err := strconv.ParseUint(raw, 10, 8); err != nil { + return err + } else { + *v = uint8(i) + } + case *uint16: + if i, err := strconv.ParseUint(raw, 10, 16); err != nil { + return err + } else { + *v = uint16(i) + } + case *uint32: + if i, err := strconv.ParseUint(raw, 10, 32); err != nil { + return err + } else { + *v = uint32(i) + } + case *uint64: + if i, err := strconv.ParseUint(raw, 10, 64); err != nil { + return err + } else { + *v = uint64(i) + } + case *float32: + if f, err := strconv.ParseFloat(raw, 32); err != nil { + return err + } else { + *v = float32(f) + } + case *float64: + if f, err := strconv.ParseFloat(raw, 64); err != nil { + return err + } else { + *v = float64(f) + } + default: + return ErrUnsupportedType + } + return nil +} diff --git a/template.go b/template.go new file mode 100644 index 0000000..e18f37d --- /dev/null +++ b/template.go @@ -0,0 +1,100 @@ +package webutil + +import ( + "embed" + "html/template" + "io/fs" + "log" + "path" + "strings" +) + +// ParseTemplateSet parses sets of templates from an embed.FS. +// +// Each directory constitutes a set of templates that are parsed together. +// +// Structure (within a directory): +// - share/* are always parsed. +// - base.html will be parsed with each other file in same dir +// +// Call a template with m[path].Execute(w, data) (root dir name is excluded). +// +// For example, if you have +// - /user/share/* +// - /user/base.html +// - /user/home.html +// +// Then you call m["/user/home.html"].Execute(w, data). +func ParseTemplateSet(funcs template.FuncMap, fs embed.FS) map[string]*template.Template { + m := map[string]*template.Template{} + rootDir := readDir(fs, ".")[0].Name() + loadTemplateDir(fs, funcs, m, rootDir, rootDir) + return m +} + +func loadTemplateDir( + fs embed.FS, + funcs template.FuncMap, + m map[string]*template.Template, + dirPath string, + rootDir string, +) map[string]*template.Template { + t := template.New("") + if funcs != nil { + t = t.Funcs(funcs) + } + + shareDir := path.Join(dirPath, "share") + if _, err := fs.ReadDir(shareDir); err == nil { + log.Printf("Parsing %s...", path.Join(shareDir, "*")) + t = template.Must(t.ParseFS(fs, path.Join(shareDir, "*"))) + } + + if data, _ := fs.ReadFile(path.Join(dirPath, "base.html")); data != nil { + log.Printf("Parsing %s...", path.Join(dirPath, "base.html")) + t = template.Must(t.Parse(string(data))) + } + + for _, ent := range readDir(fs, dirPath) { + if ent.Type().IsDir() { + if ent.Name() != "share" { + m = loadTemplateDir(fs, funcs, m, path.Join(dirPath, ent.Name()), rootDir) + } + continue + } + + if !ent.Type().IsRegular() { + continue + } + + if ent.Name() == "base.html" { + continue + } + + filePath := path.Join(dirPath, ent.Name()) + log.Printf("Parsing %s...", filePath) + + key := strings.TrimPrefix(path.Join(dirPath, ent.Name()), rootDir) + tt := template.Must(t.Clone()) + tt = template.Must(tt.Parse(readFile(fs, filePath))) + m[key] = tt + } + + return m +} + +func readDir(fs embed.FS, dirPath string) []fs.DirEntry { + ents, err := fs.ReadDir(dirPath) + if err != nil { + panic(err) + } + return ents +} + +func readFile(fs embed.FS, path string) string { + data, err := fs.ReadFile(path) + if err != nil { + panic(err) + } + return string(data) +} diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..40c9313 --- /dev/null +++ b/template_test.go @@ -0,0 +1,49 @@ +package webutil + +import ( + "bytes" + "embed" + "html/template" + "strings" + "testing" +) + +//go:embed all:test-templates +var testFS embed.FS + +func TestParseTemplateSet(t *testing.T) { + funcs := template.FuncMap{"join": strings.Join} + m := ParseTemplateSet(funcs, testFS) + + type TestCase struct { + Key string + Data any + Out string + } + + cases := []TestCase{ + { + Key: "/home.html", + Data: "DATA", + Out: "

HOME!

", + }, { + Key: "/about.html", + Data: "DATA", + Out: "

DATA

", + }, { + Key: "/contact.html", + Data: []string{"a", "b", "c"}, + Out: "

a,b,c

", + }, + } + + for _, tc := range cases { + b := &bytes.Buffer{} + m[tc.Key].Execute(b, tc.Data) + out := strings.TrimSpace(b.String()) + if out != tc.Out { + t.Fatalf("%s != %s", out, tc.Out) + } + } + +}