This commit is contained in:
jdl
2026-06-14 19:43:23 +02:00
parent eee49991b0
commit f301bec9ef
8 changed files with 320 additions and 1 deletions

View File

@@ -1,3 +1,5 @@
# webutil # webutil
A collection of utilities for making web apps. ## Roadmap
* logging middleware

43
formscanner.go Normal file
View File

@@ -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
}

10
go.mod Normal file
View File

@@ -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
)

6
go.sum Normal file
View File

@@ -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=

24
listenandserve.go Normal file
View File

@@ -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()
}

85
scanner.go Normal file
View File

@@ -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
}

100
template.go Normal file
View File

@@ -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)
}

49
template_test.go Normal file
View File

@@ -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: "<p>HOME!</p>",
}, {
Key: "/about.html",
Data: "DATA",
Out: "<p><b>DATA</b></p>",
}, {
Key: "/contact.html",
Data: []string{"a", "b", "c"},
Out: "<p>a,b,c</p>",
},
}
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)
}
}
}