Break out form unmarshal
This commit is contained in:
parent
0d793cb31a
commit
80badd0f00
12 changed files with 634 additions and 138 deletions
|
@ -29,7 +29,7 @@ alerting:
|
|||
lookbackDays: 7
|
||||
halfLife: 30m
|
||||
recent: 2h
|
||||
alertThreshold: 0.5
|
||||
alertThreshold: 0.3
|
||||
renotify: 30m
|
||||
notify:
|
||||
- provider: slackwebhook
|
||||
|
|
8
go.mod
8
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
|
||||
)
|
||||
|
|
2
go.sum
2
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=
|
||||
|
|
306
internal/forms/forms.go
Normal file
306
internal/forms/forms.go
Normal 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)
|
||||
}
|
216
internal/forms/forms_test.go
Normal file
216
internal/forms/forms_test.go
Normal 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
54
internal/forms/testdata/call1.http
vendored
Normal 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
17
internal/forms/testdata/urlenc.http
vendored
Normal 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
17
internal/forms/testdata/urlenc2.http
vendored
Normal 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
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue