Break out form unmarshal

This commit is contained in:
Daniel 2024-10-31 09:09:58 -04:00
parent 0d793cb31a
commit 80badd0f00
12 changed files with 634 additions and 138 deletions

View file

@ -29,7 +29,7 @@ alerting:
lookbackDays: 7 lookbackDays: 7
halfLife: 30m halfLife: 30m
recent: 2h recent: 2h
alertThreshold: 0.5 alertThreshold: 0.3
renotify: 30m renotify: 30m
notify: notify:
- provider: slackwebhook - provider: slackwebhook

8
go.mod
View file

@ -16,8 +16,10 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/oto v1.0.1 github.com/hajimehoshi/oto v1.0.1
github.com/jackc/pgx/v5 v5.6.0 github.com/jackc/pgx/v5 v5.6.0
github.com/nikoksr/notify v1.0.1
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
golang.org/x/sync v0.8.0 golang.org/x/sync v0.8.0
@ -29,11 +31,11 @@ require (
require ( require (
github.com/ajg/form v1.5.1 // indirect github.com/ajg/form v1.5.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/go-audio/audio v1.0.0 // indirect github.com/go-audio/audio v1.0.0 // indirect
github.com/go-audio/riff v1.0.0 // indirect github.com/go-audio/riff v1.0.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
@ -50,16 +52,14 @@ require (
github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/nikoksr/notify v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.9.0 // indirect
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect
golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/image v0.14.0 // indirect golang.org/x/image v0.14.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sys v0.26.0 // indirect golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect golang.org/x/text v0.19.0 // indirect
) )

2
go.sum
View file

@ -76,6 +76,8 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

306
internal/forms/forms.go Normal file
View file

@ -0,0 +1,306 @@
package forms
import (
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"dynatron.me/x/stillbox/internal/jsontime"
"github.com/araddon/dateparse"
)
var (
ErrNotStruct = errors.New("destination is not a struct")
ErrNotPointer = errors.New("destination is not a pointer")
)
type options struct {
tagOverride *string
parseTimeIn *time.Location
parseLocal bool
acceptBlank bool
}
type Option func(*options)
func WithParseTimeInTZ(l *time.Location) Option {
return func(o *options) {
o.parseTimeIn = l
}
}
func WithParseLocalTime() Option {
return func(o *options) {
o.parseLocal = true
}
}
func WithAcceptBlank() Option {
return func(o *options) {
o.acceptBlank = true
}
}
func WithTag(t string) Option {
return func(o *options) {
o.tagOverride = &t
}
}
func (o *options) Tag() string {
if o.tagOverride != nil {
return *o.tagOverride
}
return "form"
}
func (o *options) parseTime(s string, dpo ...dateparse.ParserOption) (t time.Time, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
if iv, err := strconv.Atoi(s); err == nil {
return time.Unix(int64(iv), 0), true, nil
}
switch {
case o.parseTimeIn != nil:
t, err = dateparse.ParseIn(s, o.parseTimeIn, dpo...)
case o.parseLocal:
t, err = dateparse.ParseLocal(s, dpo...)
default:
t, err = dateparse.ParseAny(s, dpo...)
}
set = true
return
}
func (o *options) parseBool(s string) (v bool, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.ParseBool(s)
if err != nil {
return v, set, fmt.Errorf("parsebool('%s'): %w", s, err)
}
return
}
func (o *options) parseInt(s string) (v int, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.Atoi(s)
if err != nil {
return v, set, fmt.Errorf("atoi('%s'): %w", s, err)
}
return
}
func (o *options) parseFloat64(s string) (v float64, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.ParseFloat(s, 64)
if err != nil {
return v, set, fmt.Errorf("ParseFloat('%s'): %w", s, err)
}
return
}
func (o *options) parseDuration(s string) (v time.Duration, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = time.ParseDuration(s)
if err != nil {
return v, set, fmt.Errorf("ParseDuration('%s'): %w", s, err)
}
return
}
func (o *options) iterFields(r *http.Request, rv reflect.Value) error {
rt := rv.Type()
for i := 0; i < rv.NumField(); i++ {
f := rv.Field(i)
tf := rt.Field(i)
if !tf.IsExported() && !tf.Anonymous {
continue
}
if f.Kind() == reflect.Struct && tf.Anonymous {
err := o.iterFields(r, f)
if err != nil {
return err
}
}
var tAr []string
var formField string
formTag, has := rt.Field(i).Tag.Lookup(o.Tag())
if has {
tAr = strings.Split(formTag, ",")
formField = tAr[0]
}
if !has || formField == "-" {
continue
}
fi := f.Interface()
switch v := fi.(type) {
case string, *string:
s := r.Form.Get(formField)
setVal(f, s != "" || o.acceptBlank, v, s)
case int, uint, *int, *uint:
ff := r.Form.Get(formField)
val, set, err := o.parseInt(ff)
if err != nil {
return err
}
setVal(f, set, v, val)
case float64:
ff := r.Form.Get(formField)
val, set, err := o.parseFloat64(ff)
if err != nil {
return err
}
setVal(f, set, v, val)
case bool, *bool:
ff := r.Form.Get(formField)
val, set, err := o.parseBool(ff)
if err != nil {
return err
}
setVal(f, set, v, val)
case []byte:
file, hdr, err := r.FormFile(formField)
if err != nil {
return fmt.Errorf("get form file: %w", err)
}
nameField, hasFilename := rt.Field(i).Tag.Lookup("filenameField")
if hasFilename {
fnf := rv.FieldByName(nameField)
if fnf == (reflect.Value{}) {
panic(fmt.Errorf("filenameField '%s' does not exist", nameField))
}
fnf.SetString(hdr.Filename)
}
audioBytes, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("file read: %w", err)
}
f.SetBytes(audioBytes)
case time.Time, *time.Time, jsontime.Time, *jsontime.Time:
tval := r.Form.Get(formField)
t, set, err := o.parseTime(tval)
if err != nil {
return err
}
setVal(f, set, v, t)
case time.Duration, *time.Duration, jsontime.Duration, *jsontime.Duration:
dval := r.Form.Get(formField)
d, set, err := o.parseDuration(dval)
if err != nil {
return err
}
setVal(f, set, v, d)
case []int:
val := strings.Trim(r.Form.Get(formField), "[]")
if val == "" && o.acceptBlank {
continue
}
vals := strings.Split(val, ",")
ar := make([]int, 0, len(vals))
for _, v := range vals {
i, err := strconv.Atoi(v)
if err == nil {
ar = append(ar, i)
}
}
f.Set(reflect.ValueOf(ar))
default:
panic(fmt.Errorf("unsupported type %T", v))
}
}
return nil
}
func setVal(setField reflect.Value, set bool, fv any, sv any) {
if !set {
return
}
rv := reflect.TypeOf(fv)
svo := reflect.ValueOf(sv)
if svo.CanConvert(rv) {
svo = svo.Convert(rv)
}
if rv.Kind() == reflect.Ptr {
svo = svo.Addr()
}
setField.Set(svo)
}
func Unmarshal(r *http.Request, dest any, opt ...Option) error {
o := options{}
for _, opt := range opt {
opt(&o)
}
rv := reflect.ValueOf(dest)
if k := rv.Kind(); k == reflect.Ptr {
rv = rv.Elem()
} else {
return ErrNotPointer
}
if rv.Kind() != reflect.Struct {
return ErrNotStruct
}
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
err := r.ParseForm()
if err != nil {
return fmt.Errorf("ParseForm: %w", err)
}
}
return o.iterFields(r, rv)
}

View file

@ -0,0 +1,216 @@
package forms_test
import (
"bufio"
"errors"
"net/http"
"os"
"testing"
"time"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/internal/jsontime"
"dynatron.me/x/stillbox/pkg/gordio/alerting"
"dynatron.me/x/stillbox/pkg/gordio/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type callUploadRequest struct {
Audio []byte `form:"audio" filenameField:"AudioName"`
AudioName string
AudioType string `form:"audioType"`
DateTime time.Time `form:"dateTime"`
Frequencies []int `form:"frequencies"`
Frequency int `form:"frequency"`
Key string `form:"key"`
Patches []int `form:"patches"`
Source int `form:"source"`
Sources []int `form:"sources"`
System int `form:"system"`
SystemLabel string `form:"systemLabel"`
Talkgroup int `form:"talkgroup"`
TalkgroupGroup string `form:"talkgroupGroup"`
TalkgroupLabel string `form:"talkgroupLabel"`
TalkgroupTag string `form:"talkgroupTag"`
DontStore bool `form:"dontStore"`
}
type urlEncTest struct {
LookbackDays int `form:"lookbackDays"`
HalfLife time.Duration `form:"halfLife"`
Recent string `form:"recent"`
ScoreStart time.Time `form:"scoreStart"`
ScoreEnd time.Time `form:"scoreEnd"`
}
type urlEncTestJT struct {
LookbackDays uint `json:"lookbackDays"`
HalfLife jsontime.Duration `json:"halfLife"`
Recent string `json:"recent"`
ScoreStart jsontime.Time `json:"scoreStart"`
ScoreEnd jsontime.Time `json:"scoreEnd"`
}
var (
UrlEncTest = urlEncTest{
LookbackDays: 7,
HalfLife: 30 * time.Minute,
Recent: "2h0m0s",
ScoreStart: time.Date(2024, time.October, 28, 9, 25, 0, 0, time.UTC),
}
UrlEncTestJT = urlEncTestJT{
LookbackDays: 7,
HalfLife: jsontime.Duration(30 * time.Minute),
Recent: "2h0m0s",
ScoreStart: jsontime.Time(time.Date(2024, time.October, 28, 9, 25, 0, 0, time.UTC)),
}
UrlEncTestJTLocal = urlEncTestJT{
LookbackDays: 7,
HalfLife: jsontime.Duration(30 * time.Minute),
Recent: "2h0m0s",
ScoreStart: jsontime.Time(time.Date(2024, time.October, 28, 9, 25, 0, 0, time.Local)),
}
realSim = &alerting.Simulation{
Alerting: config.Alerting{
LookbackDays: 7,
HalfLife: jsontime.Duration(30 * time.Minute),
Recent: jsontime.Duration(2 * time.Hour),
},
SimInterval: jsontime.Duration(5 * time.Minute),
ScoreStart: jsontime.Time(time.Date(2024, time.October, 22, 17, 49, 0, 0, time.Local)),
}
Call1 = callUploadRequest{
AudioName: "20241031_081939.285.mp3",
Audio: []byte("ID3dotdotdotLAME3.98.4a"),
DateTime: time.Unix(1730377177, 0),
Frequency: 852637500,
Key: "2b7c871b-abcd-defa-0123-456789abcdef",
Source: 3237,
System: 197,
SystemLabel: "RISCON",
Talkgroup: 2,
TalkgroupGroup: "Wide Area",
TalkgroupLabel: "Wide Area 1 FD/EMS Intercity",
}
)
func makeRequest(fixture string) *http.Request {
fixt, err := os.Open("testdata/" + fixture)
if err != nil {
panic(err)
}
r, err := http.ReadRequest(bufio.NewReader(fixt))
if err != nil {
panic(err)
}
return r
}
func TestUnmarshal(t *testing.T) {
var str string
tests := []struct {
name string
r *http.Request
dest any
compare any
expectErr error
opts []forms.Option
}{
{
name: "base case",
r: makeRequest("call1.http"),
dest: &callUploadRequest{},
compare: &Call1,
opts: []forms.Option{forms.WithAcceptBlank()},
},
{
name: "base case no accept blank",
r: makeRequest("call1.http"),
dest: &callUploadRequest{},
compare: &Call1,
expectErr: errors.New(`parsebool(''): strconv.ParseBool: parsing "": invalid syntax`),
},
{
name: "not a pointer",
r: makeRequest("call1.http"),
dest: callUploadRequest{},
compare: callUploadRequest{},
expectErr: forms.ErrNotPointer,
opts: []forms.Option{forms.WithAcceptBlank()},
},
{
name: "not a struct",
r: makeRequest("call1.http"),
dest: &str,
compare: callUploadRequest{},
expectErr: forms.ErrNotStruct,
opts: []forms.Option{forms.WithAcceptBlank()},
},
{
name: "url encoded",
r: makeRequest("urlenc.http"),
dest: &urlEncTest{},
compare: &UrlEncTest,
expectErr: errors.New(`Could not find format for ""`),
},
{
name: "url encoded accept blank",
r: makeRequest("urlenc.http"),
dest: &urlEncTest{},
compare: &UrlEncTest,
opts: []forms.Option{forms.WithAcceptBlank()},
},
{
name: "url encoded jsontime",
r: makeRequest("urlenc.http"),
dest: &urlEncTestJT{},
compare: &UrlEncTestJT,
expectErr: errors.New(`Could not find format for ""`),
opts: []forms.Option{forms.WithTag("json")},
},
{
name: "url encoded jsontime with tz",
r: makeRequest("urlenc.http"),
dest: &urlEncTestJT{},
compare: &UrlEncTestJT,
opts: []forms.Option{forms.WithAcceptBlank(), forms.WithParseTimeInTZ(time.UTC), forms.WithTag("json")},
},
{
name: "url encoded jsontime with local",
r: makeRequest("urlenc.http"),
dest: &urlEncTestJT{},
compare: &UrlEncTestJTLocal,
opts: []forms.Option{forms.WithAcceptBlank(), forms.WithParseLocalTime(), forms.WithTag("json")},
},
{
name: "sim real data",
r: makeRequest("urlenc2.http"),
dest: &alerting.Simulation{},
compare: realSim,
opts: []forms.Option{forms.WithAcceptBlank(), forms.WithParseLocalTime()},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := forms.Unmarshal(tc.r, tc.dest, tc.opts...)
if tc.expectErr != nil {
require.Error(t, err)
assert.Contains(t, tc.expectErr.Error(), err.Error())
} else {
require.NoError(t, err)
assert.Equal(t, tc.compare, tc.dest)
}
})
}
}

54
internal/forms/testdata/call1.http vendored Normal file
View file

@ -0,0 +1,54 @@
POST /api/call-upload HTTP/1.1
Connection: Upgrade, HTTP2-Settings
Content-Length: 5372
Host: xenon:3050
HTTP2-Settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA
Upgrade: h2c
Content-Type: multipart/form-data; boundary=--sdrtrunk-sdrtrunk-sdrtrunk
User-Agent: sdrtrunk
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="key"
2b7c871b-abcd-defa-0123-456789abcdef
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="system"
197
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="dateTime"
1730377177
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="talkgroup"
2
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="source"
3237
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="frequency"
852637500
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="talkgroupLabel"
Wide Area 1 FD/EMS Intercity
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="talkgroupGroup"
Wide Area
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="systemLabel"
RISCON
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; name="patches"
[]
----sdrtrunk-sdrtrunk-sdrtrunk
Content-Disposition: form-data; filename="20241031_081939.285.mp3"; name="audio"
ID3dotdotdotLAME3.98.4a
----sdrtrunk-sdrtrunk-sdrtrunk--

17
internal/forms/testdata/urlenc.http vendored Normal file
View file

@ -0,0 +1,17 @@
POST /tgstats HTTP/1.1
Host: xenon:3050
Connection: keep-alive
Content-Length: 98
Cache-Control: max-age=0
Origin: http://xenon:3050
DNT: 1
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://xenon:3050/tgstats
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,fr;q=0.8
sec-gpc: 1
lookbackDays=7&halfLife=30m0s&recent=2h0m0s&simInterval=5m&scoreStart=2024-10-28T09%3A25&scoreEnd=

17
internal/forms/testdata/urlenc2.http vendored Normal file
View file

@ -0,0 +1,17 @@
POST /tgstats HTTP/1.1
Host: xenon:3050
Connection: keep-alive
Content-Length: 98
Cache-Control: max-age=0
Origin: http://xenon:3050
DNT: 1
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://xenon:3050/tgstats
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,fr;q=0.8
sec-gpc: 1
lookbackDays=7&halfLife=30m0s&recent=2h0m0s&simInterval=5m0s&scoreStart=2024-10-22T17%3A49&scoreEnd=2024-10-22T19%3A50

View file

@ -101,7 +101,7 @@ func ParseAny(s string, opt ...dateparse.ParserOption) (Time, error) {
return Time(t), err return Time(t), err
} }
func ParseInLocal(s string, opt ...dateparse.ParserOption) (Time, error) { func ParseLocal(s string, opt ...dateparse.ParserOption) (Time, error) {
t, err := dateparse.ParseIn(s, time.Now().Location(), opt...) t, err := dateparse.ParseLocal(s, opt...)
return Time(t), err return Time(t), err
} }

View file

@ -7,9 +7,9 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"sort" "sort"
"strconv"
"time" "time"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/internal/jsontime" "dynatron.me/x/stillbox/internal/jsontime"
"dynatron.me/x/stillbox/internal/trending" "dynatron.me/x/stillbox/internal/trending"
cl "dynatron.me/x/stillbox/pkg/calls" cl "dynatron.me/x/stillbox/pkg/calls"
@ -24,12 +24,12 @@ type Simulation struct {
config.Alerting config.Alerting
// ScoreStart is the time when scoring begins // ScoreStart is the time when scoring begins
ScoreStart jsontime.Time `json:"scoreStart"` ScoreStart jsontime.Time `json:"scoreStart" yaml:"scoreStart" form:"scoreStart"`
// ScoreEnd is the time when the score simulator ends. Left blank, it defaults to time.Now() // ScoreEnd is the time when the score simulator ends. Left blank, it defaults to time.Now()
ScoreEnd jsontime.Time `json:"scoreEnd"` ScoreEnd jsontime.Time `json:"scoreEnd" yaml:"scoreEnd" form:"scoreEnd"`
// SimInterval is the interval at which the scorer will be called // SimInterval is the interval at which the scorer will be called
SimInterval jsontime.Duration `json:"simInterval"` SimInterval jsontime.Duration `json:"simInterval" yaml:"simInterval" form:"simInterval"`
clock offsetClock `json:"-"` clock offsetClock `json:"-"`
*alerter `json:"-"` *alerter `json:"-"`
@ -115,48 +115,12 @@ func (as *alerter) simulateHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
default: default:
err := r.ParseForm() err := forms.Unmarshal(r, s, forms.WithAcceptBlank(), forms.WithParseLocalTime())
if err != nil { if err != nil {
err = fmt.Errorf("simulate form parse: %w", err) err = fmt.Errorf("simulate unmarshal: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
lbd, err := strconv.Atoi(r.Form["lookbackDays"][0])
if err != nil {
err = fmt.Errorf("lookbackDays parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.LookbackDays = uint(lbd)
s.HalfLife, err = jsontime.ParseDuration(r.Form["halfLife"][0])
if err != nil {
err = fmt.Errorf("halfLife parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.Recent, err = jsontime.ParseDuration(r.Form["recent"][0])
if err != nil {
err = fmt.Errorf("recent parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.SimInterval, err = jsontime.ParseDuration(r.Form["simInterval"][0])
if err != nil {
err = fmt.Errorf("simInterval parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.ScoreStart, err = jsontime.ParseInLocal(r.Form["scoreStart"][0])
if err != nil {
err = fmt.Errorf("scoreStart parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.ScoreEnd, err = jsontime.ParseInLocal(r.Form["scoreEnd"][0])
if err != nil {
s.ScoreEnd = jsontime.Time{}
}
} }
err := s.verify() err := s.verify()

View file

@ -54,12 +54,12 @@ type RateLimit struct {
} }
type Alerting struct { type Alerting struct {
Enable bool `yaml:"enable"` Enable bool `yaml:"enable" form:"enable"`
LookbackDays uint `yaml:"lookbackDays"` LookbackDays uint `yaml:"lookbackDays" form:"lookbackDays"`
HalfLife jsontime.Duration `yaml:"halfLife"` HalfLife jsontime.Duration `yaml:"halfLife" form:"halfLife"`
Recent jsontime.Duration `yaml:"recent"` Recent jsontime.Duration `yaml:"recent" form:"recent"`
AlertThreshold float64 `yaml:"alertThreshold"` AlertThreshold float64 `yaml:"alertThreshold" form:"alertThreshold"`
Renotify *jsontime.Duration `yaml:"renotify,omitempty"` Renotify *jsontime.Duration `yaml:"renotify,omitempty" form:"renotify,omitempty"`
} }
type Notify []NotifyService type Notify []NotifyService

View file

@ -1,15 +1,12 @@
package sources package sources
import ( import (
"fmt"
"io"
"net/http" "net/http"
"reflect"
"strconv"
"strings" "strings"
"time" "time"
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/gordio/auth" "dynatron.me/x/stillbox/pkg/gordio/auth"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -40,7 +37,7 @@ func (h *RdioHTTP) InstallPublicRoutes(r chi.Router) {
} }
type callUploadRequest struct { type callUploadRequest struct {
Audio []byte `form:"audio"` Audio []byte `form:"audio" filenameField:"AudioName"`
AudioName string AudioName string
AudioType string `form:"audioType"` AudioType string `form:"audioType"`
DateTime time.Time `form:"dateTime"` DateTime time.Time `form:"dateTime"`
@ -115,7 +112,7 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) {
} }
cur := new(callUploadRequest) cur := new(callUploadRequest)
err = cur.fill(r) err = forms.Unmarshal(r, cur, forms.WithAcceptBlank())
if err != nil { if err != nil {
http.Error(w, "cannot bind upload "+err.Error(), http.StatusExpectationFailed) http.Error(w, "cannot bind upload "+err.Error(), http.StatusExpectationFailed)
return return
@ -141,80 +138,3 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) {
log.Error().Err(err).Int("written", written).Msg("upload response failed") log.Error().Err(err).Int("written", written).Msg("upload response failed")
} }
} }
func (car *callUploadRequest) fill(r *http.Request) error {
rv := reflect.ValueOf(car).Elem()
rt := rv.Type()
for i := 0; i < rv.NumField(); i++ {
f := rv.Field(i)
fi := f.Interface()
formField, has := rt.Field(i).Tag.Lookup("form")
if !has {
continue
}
switch v := fi.(type) {
case []byte:
file, hdr, err := r.FormFile(formField)
if err != nil {
return fmt.Errorf("get form file: %w", err)
}
car.AudioName = hdr.Filename
audioBytes, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("file read: %w", err)
}
f.SetBytes(audioBytes)
case time.Time:
tval := r.Form.Get(formField)
if iv, err := strconv.Atoi(tval); err == nil {
f.Set(reflect.ValueOf(time.Unix(int64(iv), 0)))
break
}
t, err := time.Parse(time.RFC3339, tval)
if err != nil {
return fmt.Errorf("parse time: %w", err)
}
f.Set(reflect.ValueOf(t))
case []int:
val := strings.Trim(r.Form.Get(formField), "[]")
if val == "" {
continue
}
vals := strings.Split(val, ",")
ar := make([]int, 0, len(vals))
for _, v := range vals {
i, err := strconv.Atoi(v)
if err == nil {
ar = append(ar, i)
}
}
f.Set(reflect.ValueOf(ar))
case int:
ff := r.Form.Get(formField)
val, err := strconv.Atoi(ff)
if err != nil {
return fmt.Errorf("atoi('%s'): %w", ff, err)
}
f.SetInt(int64(val))
case string:
f.SetString(r.Form.Get(formField))
case bool:
ff := r.Form.Get(formField)
if ff == "" {
continue
}
val, err := strconv.ParseBool(ff)
if err != nil {
return fmt.Errorf("parsebool('%s'): %w", ff, err)
}
f.SetBool(val)
default:
panic(fmt.Errorf("unsupported type %T", v))
}
}
return nil
}