From 80badd0f001b7324b572bca91e5944caa4ef3826 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Thu, 31 Oct 2024 09:09:58 -0400 Subject: [PATCH] Break out form unmarshal --- config.sample.yaml | 2 +- go.mod | 8 +- go.sum | 2 + internal/forms/forms.go | 306 +++++++++++++++++++++++++++ internal/forms/forms_test.go | 216 +++++++++++++++++++ internal/forms/testdata/call1.http | 54 +++++ internal/forms/testdata/urlenc.http | 17 ++ internal/forms/testdata/urlenc2.http | 17 ++ internal/jsontime/jsontime.go | 4 +- pkg/gordio/alerting/simulate.go | 48 +---- pkg/gordio/config/config.go | 12 +- pkg/gordio/sources/http.go | 86 +------- 12 files changed, 634 insertions(+), 138 deletions(-) create mode 100644 internal/forms/forms.go create mode 100644 internal/forms/forms_test.go create mode 100644 internal/forms/testdata/call1.http create mode 100644 internal/forms/testdata/urlenc.http create mode 100644 internal/forms/testdata/urlenc2.http diff --git a/config.sample.yaml b/config.sample.yaml index 14a251b..cee3a9f 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -29,7 +29,7 @@ alerting: lookbackDays: 7 halfLife: 30m recent: 2h - alertThreshold: 0.5 + alertThreshold: 0.3 renotify: 30m notify: - provider: slackwebhook diff --git a/go.mod b/go.mod index 36e4b52..5cb2a64 100644 --- a/go.mod +++ b/go.mod @@ -16,8 +16,10 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hajimehoshi/oto v1.0.1 github.com/jackc/pgx/v5 v5.6.0 + github.com/nikoksr/notify v1.0.1 github.com/rs/zerolog v1.33.0 github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.9.0 github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 golang.org/x/crypto v0.28.0 golang.org/x/sync v0.8.0 @@ -29,11 +31,11 @@ require ( require ( github.com/ajg/form v1.5.1 // 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/go-audio/audio v1.0.0 // indirect github.com/go-audio/riff v1.0.0 // 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/go-multierror v1.1.1 // 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/mattn/go-colorable v0.1.13 // 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/segmentio/asm v1.2.0 // 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 golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/image v0.14.0 // 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/text v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index da2c282..d73a798 100644 --- a/go.sum +++ b/go.sum @@ -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/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 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/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/internal/forms/forms.go b/internal/forms/forms.go new file mode 100644 index 0000000..d4bd8a1 --- /dev/null +++ b/internal/forms/forms.go @@ -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) +} diff --git a/internal/forms/forms_test.go b/internal/forms/forms_test.go new file mode 100644 index 0000000..d185455 --- /dev/null +++ b/internal/forms/forms_test.go @@ -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) + } + }) + } +} diff --git a/internal/forms/testdata/call1.http b/internal/forms/testdata/call1.http new file mode 100644 index 0000000..c819d22 --- /dev/null +++ b/internal/forms/testdata/call1.http @@ -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-- diff --git a/internal/forms/testdata/urlenc.http b/internal/forms/testdata/urlenc.http new file mode 100644 index 0000000..df53fae --- /dev/null +++ b/internal/forms/testdata/urlenc.http @@ -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= diff --git a/internal/forms/testdata/urlenc2.http b/internal/forms/testdata/urlenc2.http new file mode 100644 index 0000000..f79b184 --- /dev/null +++ b/internal/forms/testdata/urlenc2.http @@ -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 diff --git a/internal/jsontime/jsontime.go b/internal/jsontime/jsontime.go index 359ce41..9eb9425 100644 --- a/internal/jsontime/jsontime.go +++ b/internal/jsontime/jsontime.go @@ -101,7 +101,7 @@ func ParseAny(s string, opt ...dateparse.ParserOption) (Time, error) { return Time(t), err } -func ParseInLocal(s string, opt ...dateparse.ParserOption) (Time, error) { - t, err := dateparse.ParseIn(s, time.Now().Location(), opt...) +func ParseLocal(s string, opt ...dateparse.ParserOption) (Time, error) { + t, err := dateparse.ParseLocal(s, opt...) return Time(t), err } diff --git a/pkg/gordio/alerting/simulate.go b/pkg/gordio/alerting/simulate.go index ccf7cdc..445e33c 100644 --- a/pkg/gordio/alerting/simulate.go +++ b/pkg/gordio/alerting/simulate.go @@ -7,9 +7,9 @@ import ( "fmt" "net/http" "sort" - "strconv" "time" + "dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/jsontime" "dynatron.me/x/stillbox/internal/trending" cl "dynatron.me/x/stillbox/pkg/calls" @@ -24,12 +24,12 @@ type Simulation struct { config.Alerting // 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 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 jsontime.Duration `json:"simInterval"` + SimInterval jsontime.Duration `json:"simInterval" yaml:"simInterval" form:"simInterval"` clock offsetClock `json:"-"` *alerter `json:"-"` @@ -115,48 +115,12 @@ func (as *alerter) simulateHandler(w http.ResponseWriter, r *http.Request) { return } default: - err := r.ParseForm() + err := forms.Unmarshal(r, s, forms.WithAcceptBlank(), forms.WithParseLocalTime()) 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) 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() diff --git a/pkg/gordio/config/config.go b/pkg/gordio/config/config.go index 9abfe55..89fdc2a 100644 --- a/pkg/gordio/config/config.go +++ b/pkg/gordio/config/config.go @@ -54,12 +54,12 @@ type RateLimit struct { } type Alerting struct { - Enable bool `yaml:"enable"` - LookbackDays uint `yaml:"lookbackDays"` - HalfLife jsontime.Duration `yaml:"halfLife"` - Recent jsontime.Duration `yaml:"recent"` - AlertThreshold float64 `yaml:"alertThreshold"` - Renotify *jsontime.Duration `yaml:"renotify,omitempty"` + Enable bool `yaml:"enable" form:"enable"` + LookbackDays uint `yaml:"lookbackDays" form:"lookbackDays"` + HalfLife jsontime.Duration `yaml:"halfLife" form:"halfLife"` + Recent jsontime.Duration `yaml:"recent" form:"recent"` + AlertThreshold float64 `yaml:"alertThreshold" form:"alertThreshold"` + Renotify *jsontime.Duration `yaml:"renotify,omitempty" form:"renotify,omitempty"` } type Notify []NotifyService diff --git a/pkg/gordio/sources/http.go b/pkg/gordio/sources/http.go index 2a5f13f..5d39f46 100644 --- a/pkg/gordio/sources/http.go +++ b/pkg/gordio/sources/http.go @@ -1,15 +1,12 @@ package sources import ( - "fmt" - "io" "net/http" - "reflect" - "strconv" "strings" "time" "dynatron.me/x/stillbox/internal/common" + "dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/gordio/auth" "github.com/go-chi/chi/v5" @@ -40,7 +37,7 @@ func (h *RdioHTTP) InstallPublicRoutes(r chi.Router) { } type callUploadRequest struct { - Audio []byte `form:"audio"` + Audio []byte `form:"audio" filenameField:"AudioName"` AudioName string AudioType string `form:"audioType"` DateTime time.Time `form:"dateTime"` @@ -115,7 +112,7 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) { } cur := new(callUploadRequest) - err = cur.fill(r) + err = forms.Unmarshal(r, cur, forms.WithAcceptBlank()) if err != nil { http.Error(w, "cannot bind upload "+err.Error(), http.StatusExpectationFailed) 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") } } - -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 -}