WIP
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
# webutil
|
# webutil
|
||||||
|
|
||||||
A collection of utilities for making web apps.
|
## Roadmap
|
||||||
|
|
||||||
|
* logging middleware
|
||||||
|
|||||||
43
formscanner.go
Normal file
43
formscanner.go
Normal 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
10
go.mod
Normal 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
6
go.sum
Normal 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
24
listenandserve.go
Normal 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
85
scanner.go
Normal 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
100
template.go
Normal 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
49
template_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user