This commit is contained in:
jdl
2026-06-14 20:00:36 +02:00
parent f301bec9ef
commit 43067677ec
10 changed files with 164 additions and 4 deletions

52
formscanner_test.go Normal file
View File

@@ -0,0 +1,52 @@
package webutil
import (
"net/url"
"reflect"
"testing"
)
func TestFormScannerTypes(t *testing.T) {
cases := []struct {
name string
form url.Values
dest any
want any
wantErr bool
}{
{name: "string", form: url.Values{"f": {"hello"}}, dest: new(string), want: "hello"},
{name: "int", form: url.Values{"f": {"42"}}, dest: new(int), want: int(42)},
{name: "int8", form: url.Values{"f": {"8"}}, dest: new(int8), want: int8(8)},
{name: "int16", form: url.Values{"f": {"16"}}, dest: new(int16), want: int16(16)},
{name: "int32", form: url.Values{"f": {"32"}}, dest: new(int32), want: int32(32)},
{name: "int64", form: url.Values{"f": {"64"}}, dest: new(int64), want: int64(64)},
{name: "uint", form: url.Values{"f": {"1"}}, dest: new(uint), want: uint(1)},
{name: "uint8", form: url.Values{"f": {"8"}}, dest: new(uint8), want: uint8(8)},
{name: "uint16", form: url.Values{"f": {"16"}}, dest: new(uint16), want: uint16(16)},
{name: "uint32", form: url.Values{"f": {"32"}}, dest: new(uint32), want: uint32(32)},
{name: "uint64", form: url.Values{"f": {"64"}}, dest: new(uint64), want: uint64(64)},
{name: "float32", form: url.Values{"f": {"1.5"}}, dest: new(float32), want: float32(1.5)},
{name: "float64", form: url.Values{"f": {"1.5"}}, dest: new(float64), want: float64(1.5)},
{name: "bool/present", form: url.Values{"f": {""}}, dest: new(bool), want: true},
{name: "bool/absent", form: url.Values{}, dest: new(bool), want: false},
{name: "int/invalid", form: url.Values{"f": {"abc"}}, dest: new(int), wantErr: true},
{name: "unsupported type", form: url.Values{"f": {"x"}}, dest: new([]string), wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := NewFormScanner(tc.form)
s.Scan("f", tc.dest)
err := s.Error()
if tc.wantErr != (err != nil) {
t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
}
if !tc.wantErr {
got := reflect.ValueOf(tc.dest).Elem().Interface()
if got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
}
})
}
}

28
structscanner.go Normal file
View File

@@ -0,0 +1,28 @@
package webutil
import (
"errors"
"net/url"
"reflect"
)
// ScanStruct scans form values into the exported fields of dest (must be a
// pointer to a struct) using field names as form keys. Bool fields are set to
// true when the key is present, matching HTML checkbox behaviour.
func ScanStruct(form url.Values, dest any) error {
v := reflect.ValueOf(dest)
if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
return errors.New("ScanStruct: dest must be a pointer to a struct")
}
v = v.Elem()
s := NewFormScanner(form)
for key := range form {
fv := v.FieldByName(key)
if !fv.IsValid() || !fv.CanSet() {
continue
}
s.Scan(key, fv.Addr().Interface())
}
return s.Error()
}

78
structscanner_test.go Normal file
View File

@@ -0,0 +1,78 @@
package webutil
import (
"net/url"
"testing"
)
func TestScanStruct(t *testing.T) {
t.Run("fields scanned by name", func(t *testing.T) {
type S struct {
Name string
Age int
Active bool
}
var s S
err := ScanStruct(url.Values{
"Name": {"Alice"},
"Age": {"30"},
"Active": {"on"},
}, &s)
if err != nil {
t.Fatal(err)
}
if s.Name != "Alice" || s.Age != 30 || !s.Active {
t.Fatalf("unexpected: %+v", s)
}
})
t.Run("missing form key leaves zero value", func(t *testing.T) {
type S struct {
Name string
Age int
}
var s S
if err := ScanStruct(url.Values{"Name": {"Bob"}}, &s); err != nil {
t.Fatal(err)
}
if s.Name != "Bob" || s.Age != 0 {
t.Fatalf("unexpected: %+v", s)
}
})
t.Run("extra form keys ignored", func(t *testing.T) {
type S struct{ Name string }
var s S
if err := ScanStruct(url.Values{"Name": {"Alice"}, "Extra": {"ignored"}}, &s); err != nil {
t.Fatal(err)
}
})
t.Run("unexported fields skipped", func(t *testing.T) {
type S struct {
Name string
secret string
}
var s S
if err := ScanStruct(url.Values{"Name": {"Alice"}, "secret": {"oops"}}, &s); err != nil {
t.Fatal(err)
}
if s.secret != "" {
t.Fatal("unexported field should not be set")
}
})
t.Run("parse error", func(t *testing.T) {
type S struct{ Age int }
var s S
if err := ScanStruct(url.Values{"Age": {"not-a-number"}}, &s); err == nil {
t.Fatal("expected error")
}
})
t.Run("requires pointer to struct", func(t *testing.T) {
if err := ScanStruct(url.Values{}, "not a struct"); err == nil {
t.Fatal("expected error")
}
})
}

View File

@@ -4,7 +4,6 @@ import (
"embed"
"html/template"
"io/fs"
"log"
"path"
"strings"
)
@@ -46,12 +45,10 @@ func loadTemplateDir(
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)))
}
@@ -72,7 +69,6 @@ func loadTemplateDir(
}
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())

View File

@@ -0,0 +1 @@
{{define "body"}}{{template "bold" .}}{{end}}

1
test-templates/base.html Normal file
View File

@@ -0,0 +1 @@
<p>{{block "body" .}}default{{end}}</p>

View File

@@ -0,0 +1 @@
{{define "body"}}{{join . ","}}{{end}}

1
test-templates/home.html Normal file
View File

@@ -0,0 +1 @@
{{define "body"}}HOME!{{end}}

View File

@@ -0,0 +1 @@
{{define "bold"}}<b>{{.}}</b>{{end}}

View File

@@ -0,0 +1 @@
{{define "italic"}}<i>{{.}}</i>{{end}}