From 59909aef58c03ab6439951d07821896c4cbb8f1c Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 22 Dec 2021 06:38:54 +0500 Subject: [PATCH] :sparkles: Created simple form values decoding --- form.go | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++ form_test.go | 83 +++++++++++++++++++++++++++++++ go.mod | 14 ++++++ go.sum | 31 ++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 form.go create mode 100644 form_test.go create mode 100644 go.sum diff --git a/form.go b/form.go new file mode 100644 index 0000000..050c02b --- /dev/null +++ b/form.go @@ -0,0 +1,137 @@ +// Package form implements encoding and decoding of urlencoded form. The mapping +// between form and Go values is described by `form:"query_name"` struct tags. +package form + +import ( + "errors" + "fmt" + "reflect" + + http "github.com/valyala/fasthttp" +) + +type ( + // Unmarshaler is the interface implemented by types that can unmarshal + // a form description of themselves. The input can be assumed to be a + // valid encoding of a form value. UnmarshalForm must copy the form data + // if it wishes to retain the data after returning. + // + // By convention, to approximate the behavior of Unmarshal itself, + // Unmarshalers implement UnmarshalForm([]byte("null")) as a no-op. + Unmarshaler interface { + UnmarshalForm(v []byte) error + } + + // A Decoder reads and decodes form values from an *fasthttp.Args. + Decoder struct { + source *http.Args + } +) + +const tagName string = "form" + +// NewDecoder returns a new decoder that reads from *fasthttp.Args. +func NewDecoder(args *http.Args) *Decoder { + return &Decoder{ + source: args, + } +} + +// Unmarshal parses the form-encoded data and stores the result in the value +// pointed to by v. If v is nil or not a pointer, Unmarshal returns error. +// +// Unmarshal uses the reflection, allocating maps, slices, and pointers as +// necessary, with the following additional rules: +// +// To unmarshal form into a pointer, Unmarshal first handles the case of the +// form being the form literal null. In that case, Unmarshal sets the pointer to +// nil. Otherwise, Unmarshal unmarshals the form into the value pointed at by +// the pointer. If the pointer is nil, Unmarshal allocates a new value for it to +// point to. +// +// To unmarshal form into a value implementing the Unmarshaler interface, +// Unmarshal calls that value's UnmarshalForm method, including when the input +// is a form null. +// +// To unmarshal form into a struct, Unmarshal matches incoming object keys to +// the keys (either the struct field name or its tag), preferring an exact match +// but also accepting a case-insensitive match. By default, object keys which +// don't have a corresponding struct field are ignored. +func Unmarshal(src *http.Args, dst interface{}) error { + if err := NewDecoder(src).Decode(dst); err != nil { + return fmt.Errorf("unmarshal: %w", err) + } + + return nil +} + +// Decode reads the next form-encoded value from its input and stores it in the +// value pointed to by v. +//nolint: funlen +func (dec *Decoder) Decode(src interface{}) (err error) { + v := reflect.ValueOf(src).Elem() + if !v.IsValid() { + return errors.New("invalid input") + } + + defer func() { + if r := recover(); r != nil { + if ve, ok := r.(*reflect.ValueError); ok { + err = fmt.Errorf("recovered: %w", ve) + } else { + panic(r) + } + } + }() + + t := reflect.TypeOf(src).Elem() + + for i := 0; i < v.NumField(); i++ { + ft := t.Field(i) + + // NOTE(toby3d): get tag value as query name + tagValue, ok := ft.Tag.Lookup(tagName) + if !ok || tagValue == "" || tagValue == "-" || !dec.source.Has(tagValue) { + continue + } + + field := v.Field(i) + + // NOTE(toby3d): read struct field type + switch ft.Type.Kind() { + case reflect.String: + field.SetString(string(dec.source.Peek(tagValue))) + case reflect.Int: + field.SetInt(int64(dec.source.GetUintOrZero(tagValue))) + case reflect.Float64: + field.SetFloat(dec.source.GetUfloatOrZero(tagValue)) + case reflect.Bool: + field.SetBool(dec.source.GetBool(tagValue)) + case reflect.Ptr: // NOTE(toby3d): pointer to another struct + // NOTE(toby3d): check what custom unmarshal method exists + unmarshalFunc := field.MethodByName("UnmarshalForm") + if unmarshalFunc.IsZero() { + continue + } + + field.Set(reflect.New(ft.Type.Elem())) // NOTE(toby3d): initialize zero value + unmarshalFunc.Call([]reflect.Value{reflect.ValueOf(dec.source.Peek(tagValue))}) + case reflect.Slice: + switch ft.Type.Elem().Kind() { + case reflect.Uint8: // NOTE(toby3d): bytes slice + field.SetBytes(dec.source.Peek(tagValue)) + case reflect.String: // NOTE(toby3d): string slice + values := dec.source.PeekMulti(tagValue) + slice := reflect.MakeSlice(ft.Type, len(values), len(values)) + + for j, vv := range values { + slice.Index(j).SetString(string(vv)) + } + + field.Set(slice) + } + } + } + + return +} diff --git a/form_test.go b/form_test.go new file mode 100644 index 0000000..048b263 --- /dev/null +++ b/form_test.go @@ -0,0 +1,83 @@ +package form_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + http "github.com/valyala/fasthttp" + + "source.toby3d.me/toby3d/form" +) + +type ( + ResponseType string + + URI struct { + *http.URI `form:"-"` + } + + TestResult struct { + State []byte `form:"state"` + Scope []string `form:"scope[]"` + ClientID *URI `form:"client_id"` + RedirectURI *URI `form:"redirect_uri"` + Me *URI `form:"me"` + ResponseType ResponseType `form:"response_type"` + CodeChallenge string `form:"code_challenge"` + CodeChallengeMethod string `form:"code_challenge_method"` + } +) + +const testData string = `response_type=code` + // NOTE(toby3d): string type alias + `&state=1234567890` + // NOTE(toby3d): raw value + // NOTE(toby3d): custom URL types + `&client_id=https://app.example.com/` + + `&redirect_uri=https://app.example.com/redirect` + + `&me=https://user.example.net/` + + // NOTE(toby3d): plain strings + `&code_challenge=OfYAxt8zU2dAPDWQxTAUIteRzMsoj9QBdMIVEDOErUo` + + `&code_challenge_method=S256` + + // NOTE(toby3d): multiple values + `&scope[]=profile` + + `&scope[]=create` + + `&scope[]=update` + + `&scope[]=delete` + +func TestUnmarshal(t *testing.T) { + t.Parallel() + + args := http.AcquireArgs() + clientId, redirectUri, me := http.AcquireURI(), http.AcquireURI(), http.AcquireURI() + + t.Cleanup(func() { + http.ReleaseURI(me) + http.ReleaseURI(redirectUri) + http.ReleaseURI(clientId) + http.ReleaseArgs(args) + }) + + require.NoError(t, clientId.Parse(nil, []byte("https://app.example.com/"))) + require.NoError(t, redirectUri.Parse(nil, []byte("https://app.example.com/redirect"))) + require.NoError(t, me.Parse(nil, []byte("https://user.example.net/"))) + args.Parse(testData) + + result := new(TestResult) + require.NoError(t, form.Unmarshal(args, result)) + assert.Equal(t, &TestResult{ + ClientID: &URI{URI: clientId}, + Me: &URI{URI: me}, + RedirectURI: &URI{URI: redirectUri}, + State: []byte("1234567890"), + Scope: []string{"profile", "create", "update", "delete"}, + CodeChallengeMethod: "S256", + CodeChallenge: "OfYAxt8zU2dAPDWQxTAUIteRzMsoj9QBdMIVEDOErUo", + ResponseType: "code", + }, result) +} + +func (src *URI) UnmarshalForm(v []byte) error { + src.URI = http.AcquireURI() + + return src.Parse(nil, v) +} diff --git a/go.mod b/go.mod index c18d87b..8662e77 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module source.toby3d.me/toby3d/form go 1.17 + +require ( + github.com/stretchr/testify v1.7.0 + github.com/valyala/fasthttp v1.31.0 +) + +require ( + github.com/andybalholm/brotli v1.0.2 // indirect + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/klauspost/compress v1.13.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bbc35a1 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E= +github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s= +github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.31.0 h1:lrauRLII19afgCs2fnWRJ4M5IkV0lo2FqA61uGkNBfE= +github.com/valyala/fasthttp v1.31.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=